#2022-11-06: Multi-column layouts
Experimenting with newspaper design.#Columns
I can't stop thinking about newspaper-like layouts with many narrow columns.
In the current single wide-column layout on niedzielski.com, there is an unhappy tension between
- wanting that column to be narrow enough for the eye to easily track from the end of one line to the start of the next (ie, not classic Wikipedia), and
- making that narrow column excessively long while the left and right remain empty voids.
At time of writing, the design looks like:
Ignoring the rough edges, the same article in a multi-columnar layout with column snapping.
(This article on multi-column CSS helped me prototype the above.)
So, here are the pros as I see them:
- Narrow columns are easiest to read.
- Multi-column layouts have excellent content density.
- Inline images don't need to be huge.
- Improved content density reduces scrolling.
Juxtaposed with the cons:
- Scrolling is cumbersome. On desktop, I usually shift-scroll to advance the content and I don't think many people know about that. On a phone, it's interestingly just a single-column with paging.
- The design is unfamiliar.
- The content density can be overwhelming.
- Customizing presentation isn't well supported. It's hard to do anything beyond the basics across form-factors. Styling is quite limited and challenging.
- Many other content types (images, video, code, etc) often don't fit well. These need to opened in a dedicated dialog-like viewer.
- The eye jump from the bottom of one column to the top of another is a full screen.
What's friendliest? Probably single wide-column. I just don't think multi-column is really a practical template for most content I write.
A larger screenshot of the above:
A similar screenshot but for Wikipedia with a multi-column experimental style:
A book layout:
Scan by Internet Archive from Neuromancer; © 1984 William Gibson.
Interestingly, lynx also dumps a single column layout.
$ lynx -dump niedzielski.com/log/2022-09-05
* [1]NIEDZIELSKI
* [2]Works
* [3]Notes
* [4]Log
* [5]Profile
[6]#2022-09-05: Pixel perfect
Some longstanding game development challenges resolve nicely.
It's early September, still hot, rainier than I remember in seasons
past, and the crickets chirp loudly late into the night.
Work is coming along slowly but nicely for my tiny solitaire video
game. The underlying goal is to return to developing my primary
creative pursuit, Nature Elsewhere. By completion of solitaire, I hope
to have established my own little from-scratch platform for expression
that I can build upon as a necessary side-effect.
[7]#Sawfish Solitaire and naming
If only to keep things simple, I've resolved to abandon [8]all of the
Sawfish ideas in the last post except for the inexplicable Patience the
Demon and the card faces which will be semi-nature or evolution themed.
This cool color variation of the oidoid logo didn't make the cut
either:
[9]The oidoid logo.
I've also dropped the Sawfish Solitaire name. The new working title is
Sublime Solitaire which I chose using my usual constraint of .com
availability. I picked the name using some JavaScript-y approximation
of [10]the CD Baby guy's method for finding available .com domains.
I've published the code under [11]whois-local. I will probably change
the name again.
I'm glad I don't have to keep using the whois command-line tool which,
due to multiple domain scoops, has me paranoid whether my queries are
as private as I thought.
[12]#Pixel perfect WebGL
Exact pixel graphics are essential to what I wish to express. I chose
pixel art in part to do something small well. Yet, inconsistencies have
plagued development from the beginning (years!). It was a major
motivation to build my own game engine instead of using an existing one
like Phaser 2.
[13]#One pixel is a lot
These glitches are often subtle for many graphics and only occur in
specific configurations. In the following real example, a column of
pixels is truncated out of the middle of the canvas on a certain window
size as I changed the browser zoom level:
[14]Example of pixel glitches.
I would be unlikely to notice the above inconsistency in a static image
due to the nature of the illustration. However, the bug is glaring for
some renders. I think the vertical card borders, in this case, would
probably be the most noticeable single pixel difference.
The magnitude of the issue is difficult to convey but maybe a
comparable musical analogy is that it's like being off-key or a little
out of sync. You might not notice it in some songs but it would ruin
others and, as a musician, you have to fix it or learn to incorporate
it.
[15]#Ideal
In time, I identified the desired behavior as something like:
1. Given sufficient physical or native pixels, the camera will be
guaranteed to be at least a minimum width and height. Assuming a
minimum working area makes it easier to build screens and levels.
In solitaire, for example, a minimum width guarantees the tableau
piles can always be laid out horizontally.
2. The minimum camera area will be scaled to the greatest integer
where both the scaled width and scaled height fit. Any remaining
space will be rendered as well. Eg, a minimum camera of 160 px ×
144 px could render at 2x in a 320 px² window with an additional 32
px × 320 px rendered. Shrinking the window or available native
pixels will shrink the level scale as needed to ensure the working
area is always rendered.
3. The scaled or level pixels may not be a multiple of the native
window dimensions. Up to one level pixel (technically, scale - 1
px) will be clipped by the window on both axes as needed.
4. The browser zoom will be totally inert since the native pixels
available is unchanging.
5. No fancy portrait / landscape flipping initially.
[16]#Too many variables
There's a ridiculous number of variables that effect scaling, some
interdependent, including:
* Window or client size.
* Body dimensions (width and height), margin, and overflow.
* Canvas attribute dimensions.
* Canvas display, image-rendering, and width and height style
properties.
* Browser zoom and devicePixelRatio.
* Minimum camera size and the camera transform.
The above along with the usual compounding factors of development, such
as no real integer type, has made this a tricky interplay of bugs to
solve.
[17]#Altogether
When it's working properly, a rendered checkerboard pattern will appear
uniform and doesn't change size at any browser zoom. In practice, I've
found it to be the most effective and confident test.
[18]Example of as pixel perfect as it gets.
In my thinking, truncating the last level pixel on both axes as needed
is the best tradeoff. Highlighted in red in the above example (click
for a larger view), you can see the the last level pixel gets truncated
by the window depending on dimensions as the scaled pixel is not
necessarily an even multiple of the native window size. At first I
thought, "I think I can render that last partial level pixel correctly
instead of letting the window halve it," but then I realized that even
if I did render it as a half-pixel, it would look identical and appear
truncated by the window. Worse, the camera would have to be represented
with fractional values instead of integral.
The other approach I considered is that if the partial pixel was
omitted by shrinking the camera width to 415 px (Math.floor( window /
scale ) instead of Math.ceil()), the window would have an unrendered
native 1 px gap (.5 level pixels). However, this gap can be as large as
scale - 1 so I think going over most frequently looks best.
I'm probably about as satisfied as I'll ever be with the solution. It
doesn't force a window size on the user, the UI (only Patience and the
background right now) can follow the screen edge at nice tile-sized
intervals to keep the rhythm, and the scaling is perfectly proportional
integers. The only shortcoming is the last column and row may be
truncated. If I keep the minimum camera size a multiple of common
display dimensions, full-screen will often be pixel perfect.
Tangentially, I've also added a crisp 16 px² favicon.
[19]#Simpler, faster sprites
Sprites are rendering primitives and they've received some major
simplifications recently.
[20]#On GPU sprite look-ups
In the prior implementation, sprites were not tile-based. They could be
any size so stitching together multiple tiles into meta-tile sprites
was never necessary (except for layering and composition effects). I've
retained that design but previously the renderer would send the source
image location (x, y, width, and height) for every sprite instance.
Now, a look-up table by animation ID is loaded on the GPU once. This
provides the same functionality and reduces bytes sent to a two-byte
identifier instead of eight but more importantly, simplifies the sprite
data layout to be as basic as a tile-based sprite. This kind of dead
simple mapping between atlas source and render destination is easier to
think about: "The only supported source is a predefined region
specified by ID. I can map this source image to anywhere in the level
at any size."
[21]#No more sprite sorting and fewer layers
Nature Elsewhere used [22]the painter's algorithm to draw sprites on
top of each other in the correct order. Of note, the old naive
implementation did all sorting on the CPU. The new implementation
converts a logical layer like "Background" to a z-depth and discards
any covered fragments, which allows the GPU to compose all the sprites
regardless of order. [23]This OpenGL z-buffer article presents the
topic well.
In Nature Elsewhere, if two sprites were on the same layer, I often
wanted the sprite further down the screen to appear in front. For
example, a tree sprite should be rendered in front of a bee sprite if
the bottom of the tree was further down the screen than the bee and
vice versa. The painter's algorithm often worked well for this: sort by
later and within a layer sort by y + spriteHeight. However, one case it
didn't work well for was UI which had an escalating layer / z-index
battle like "UILo", "UIMid", "UIHi", and "UIHiHi" to support composing
dialog borders, dialog backgrounds, button borders, and button text in
the correct order. I think I also had to use masking.
+-------------------------------------+
| Dialog border |
| +----------------------------------+|
| | Dialog background ||
| | +----------------+ ||
| | | Button border | ||
| | | +-------------+| ||
| | | | Button text || ||
| | | +-------------+| ||
| | +----------------+ ||
| +----------------------------------+|
+-------------------------------------+
The new renderer simplifies intra-layer resolution by adding a bit for
flagging whether a sprite on a given layer should be sorted by the
start or the end position. This is only used to resolve order within a
layer. In the above example, the dialog border is large and spans the
from the top of the screen to the bottom. If I flag that intra-layer
order conflicts should be resolved by the end position, the border will
always "win" (be drawn in front) because it extends to the bottom of
the screen. However, if I flag that the top of the sprite should be
used, it'll never win and be drawn in the back because it starts at the
top of the screen.
[24]#Multi-sprite entities
In general, I want to avoid layering and multi-sprite entities as they
were a source of complexity in the old Nature Elsewhere implementation.
It's easy to imagine the cards being composed in-game from a suit
sprite, rank sprite, face sprite, and card blank sprite but in the
spirit of simplicity I've pre-baked all the cards as single sprite
entities. This increases the sprite sheet size but they're not animated
so there's room to spare. I still do some layering with the UI
backgrounds though and would like to explore a better masking and
composition in a [25]9-patch-like sprite implementation, something I
never got far into in past Nature Elsewhere work.
[26]#More bitflags
I've started using bitflags over dedicated fields. Presumably, there's
a microscopic performance penalty for masking out writes correctly but
reads and GPU transmission are essentially free and the sprite
primitive stays nicely compact whether it needs to leverage special
flags or not. [27]JavaScript safe integers are 53-bits wide (6+ bytes)
and I anticipate most of the data in my games will be 16 bits or less,
so there's lots of room.
As an example, texture wrapping offsets are now two nibbles (one byte).
I use texture wrapping in several places of Nature Elsewhere including
rain and [28]marching-ants UI. However, most sprites do not care and
don't have to carry around extra fields (four bytes whether it was used
or not) for these special cases. I am much more excited for the
slimmer, simpler sprites than the performance savings.
[29]#Apple won
Some years back I was shocked to find that all my devices supported
WebGL v2 which had been out for a long time but my partner's iPad did
not. Apparently, it was well-known that Apple devices only supported
WebGL v1 so I had to downgrade but I imagined myself in a personal
competition to release Nature Elsewhere before Apple released WebGL v2
support. Well, they won so now I'm back on v2 and there were some
modest improvements to the renderer. It's mostly just nice to not have
to worry about v1-specifics.
It will be neat to see what WebGPU brings and I hope to one day publish
a Deno desktop WebGPU app without too much cruft.
[30]#Making due with Make
I had some deep experiences with GNU Make early in my professional life
that left a mark. I am continually surprised that I haven't found a
modern alternative for it, perhaps Rust-based, that eliminates it's
innumerable foot-guns, scalability issues, unfun syntax, and other
limitations. The closest I've seen is [31]just which looks promising
but unfortunately keeps some of the syntax I dislike and drops all
file-based dependency support. Make is just so useful so, once again, I
somehow find myself using Make.
In recent years, I had been using a crufty [32]shebang trick to mark
the makefile itself as executable and force a bunch of useful command
line flags like --jobs and --warn-undefined-variables. Not long ago,
some wild child even added the --split-string flag to env so you can
put it all in the shebang itself. Nevertheless, I didn't like the
shebang approach because it's less obvious what a script called make
does when you encounter a new project vs makefile or package.json, it's
a little more comfortable to type make than ./make, and the strange mix
of shell and Make syntax confuses my editor.
Many of these command-line flags have special variable equivalents like
[33]MAKEFLAGS, allowing for much nicer vanilla makefiles. [34]I've
captured my current thinking in this template. In doing so, and just
when I thought "oh, I've finally got all this crup sorted," I stumbled
over [35]some 20+ year old bug that I think was why I stared using the
shebang approach. In discourse, however, it turns out to have already
been fixed (!) but [36]the last release of Make was a couple years
back. I look forward to the next release.
One last thing I think I only recently realized was that I can de facto
fork pretty painlessly in a makefile by virtue of using --jobs which I
want on anyway. Any recipes that have long-running processes like
watchers execute in their own job so no fancy traps are really needed
so far as I know.
I think I started using Make at v3.80 for Cygwin. It's nice that it
only took me 14 years or so to draw these conclusions.
[37]#Entities, Components, Systems
I built out the most modest ECS I could after reading this article on
[38]a simple TypeScript implementation and another on [39]a performant
C version. It's very early in development and I haven't made any
performance improvements but I really like how its isolated what should
be disparate pieces of game logic. In some ways, it doesn't seem too
far from what I had previously, and adapting my "follow cam" ECS-like
to a more proper ECS required few changes, for instance, but the
concepts are a lot clearer to me.
[40]#JavaScript integral types
I am still waffling a bit on my branded type implementation for integer
values. I'm pretty happy with it overall and have written it such that
supporting widths from U4/I4 to U32/I32 is a minimum of code but maybe
I should switch to bigints.
[41]#Input profiling
I've adding some input latency measurements to my input states and plan
to expose these in some kind of debug pane. Pointer events are maybe 6
ms behind on average but, when using a stylus, the delay feels much
longer.
Summer's end
So, everything seems to be coming together nicely and many of the
problems I've had historically in Nature Elsewhere and before have been
dissolving in really pleasing ways. Maybe I'm benefiting from prior
experience, improved tools, having some distance from the problems,
more study time, many smaller projects in-between, or all of the
aforementioned. Whatsoever the reason, I can't wait to get this big
monorepo-ish platform put together and published.
Programming is at the center of my life. I had a wonderful trip to
Rocky Mountain National Park and I bought two bucket hats last week.
[42]As good as it gets.
© Stephen Niedzielski. This page was published on 2022-09-05 and
modified 2022-09-05.
References
1. https://niedzielski.com/
2. https://niedzielski.com/works
3. https://niedzielski.com/notes
4. https://niedzielski.com/log
5. https://niedzielski.com/stephen
6. https://niedzielski.com/log/2022-09-05/#title
7. https://niedzielski.com/log/2022-09-05/#sawfish-solitaire-and-naming
8. https://niedzielski.com/log/2022-06-29
9. https://niedzielski.com/log/2022-09-05/oidoid.png
10. https://sive.rs/com
11. https://github.com/niedzielski/whois-local
12. https://niedzielski.com/log/2022-09-05/#pixel-perfect-webgl
13. https://niedzielski.com/log/2022-09-05/#one-pixel-is-a-lot
14. https://niedzielski.com/log/2022-09-05/pixel-glitches.gif
15. https://niedzielski.com/log/2022-09-05/#ideal
16. https://niedzielski.com/log/2022-09-05/#too-many-variables
17. https://niedzielski.com/log/2022-09-05/#altogether
18. https://niedzielski.com/log/2022-09-05/pixel-perfect.png
19. https://niedzielski.com/log/2022-09-05/#simpler-faster-sprites
20. https://niedzielski.com/log/2022-09-05/#on-gpu-sprite-look-ups
21. https://niedzielski.com/log/2022-09-05/#no-more-sprite-sorting-and-fewer-layers
22. https://wikipedia.org/wiki/Painter's_algorithm
23. https://www.patternsgameprog.com/opengl-2d-facade-24-z-buffer
24. https://niedzielski.com/log/2022-09-05/#multi-sprite-entities
25. https://developer.android.com/develop/ui/views/graphics/drawables#nine-patch
26. https://niedzielski.com/log/2022-09-05/#more-bitflags
27. https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER
28. https://wikipedia.org/wiki/Marching_ants
29. https://niedzielski.com/log/2022-09-05/#apple-won
30. https://niedzielski.com/log/2022-09-05/#making-due-with-make
31. https://github.com/casey/just
32. https://github.com/niedzielski/shebang/blob/e7bfa9d6392ac34bd0702631ca01fd1ec678465e/demo/make#L2
33. https://www.gnu.org/software/make/manual/make.html#Options_002fRecursion
34. https://github.com/niedzielski/makefile-skeleton
35. https://savannah.gnu.org/bugs/?9060
36. https://git.savannah.gnu.org/cgit/make.git/tag/?h=4.3
37. https://niedzielski.com/log/2022-09-05/#entities-components-systems
38. https://maxwellforbes.com/posts/typescript-ecs-implementation
39. https://austinmorlan.com/posts/entity_component_system
40. https://niedzielski.com/log/2022-09-05/#javascript-integral-types
41. https://niedzielski.com/log/2022-09-05/#input-profiling
42. https://niedzielski.com/log/2022-09-05/stephen-2022-08-30.jpeg
#Black and white
The other day I read the most interesting quote on the Casablanca Wikipedia article:
"If you're going to colorize Casablanca, why not put arms on the Venus de Milo?" ―Stephen Humphrey Bogart
Photo by Livioandronico2013 from the Venus de Milo Wikipedia article; distributed under a CC BY-SA 4.0 license.
Both would restore something lost. So fun to think on!
#Sublime Solitaire
Solitaire is feature complete! So much work left to do in refactoring, bug fixing, getting the repo in a state to publish, and drawing all the card art but the long, long slog of feature work like rebuilding the renderer from scratch has wrapped up and most of the remaining lengthy tasks are enjoyable and easily approached in any state of drowsiness, if only because the scope feels finite. I've even started pushing code again!!
#Num, XY, and Box
Those integer, Cartesian pair, and rectangle datums I was bragging about being "a minimum of code" have exploded in size and complexity.
The implementations are gnarly but the API has worked well enough so far. I think my primary complaints are:
- The implementation is very hard to read and weird.
- The many pros and cons of using a branded data type instead of a nominal class.
#Num
The number APIs look like this:
let i = I4(-4); // Make a signed nibble of value -4.
i = I4(i + 8); // Add 8 to it, 4.
try {
i = I4(i + 4); // Try to add 4 more (throws), 4.
} catch {}
i = I4.floor(i + 4.5); // Saturating add 4.5, 7.
I think I've worked out all the issues save one, bitcasts. What I mean is, interpreting a JavaScript number as a two's complement encoded value, not a logical number. I use BigInts to support the full range of Number and it doesn't quite fit in with the rest of the implementation so I am thinking on that more.
Rebranding Num types as other types like Millis
seem to work well.
I think everything else is doing ok actually.
#XY
I've dropped WH, width-height, to focus on a single Cartesian pair type, XY. It's a monster at close to 500 lines of odd, hard-to-read code. However, it supports the complete variety of Num types (I4, I8, I16, I32, Int, Number, and the corresponding unsigned variants) with a variety of useful arithmetic methods (add, subtract, divide, multiply) and other operations like area, lerp, and absolute value. All methods support the complete pack of Num conversions wherever it makes sense: truncate, round, floor, ceiling, modulo, and clamp which has been super useful to have abstracted.
This type doesn't work as well as Num though:
- Unlike Num, XY doesn't magically unwrap in arithmetic operations like
IntXY(1, 2) + IntXY(3, 4)
which wouldn't compile. You have to use the corresponding method likeIntXY.add(IntXY(1, 2), 3, 4)
. - XY required it's own layer of branding beyond Num because it's a mutable
non-primitive. I had to take care to forbid mixing wide methods with narrow
types like
I16XY.add(I8XY(1, 1), 1000, 1000)
. For Nums like I16, this still makes sense since it'll return a new I16 butI16XY
mutates in-place which would silently create invalid out-of-bounds states.
The only other issue I've noticed is if you spread an XY into another type, then you brand that type, and I haven't thought about what the implications are of that enough.
The API seems to be hiding things well in how I use it at least:
const xy = U32XY(1, 2); // Make an unsigned 32b XY pair, (1, 2).
U32XY.add(xy, 4, 8); // Add (4, 8), (5, 10).
U32XY.divRound(xy, 2, 2); // Divide by 2 and round, (3, 5).
try {
U32XY.mul(xy, IntXY(-1, -1)); // Try to multiply by -1 (throws), (3, 5).
} catch {}
The APIs all return the location value (xy
in the above example) and will look
much nicer if the
pipeline operator succeeds
but still a bit clumsy.
#Box
Any problems I had with Num and XY flowed upwards into Box, two points describing a rectangle, which builds on both. At close to 700 lines of convoluted code, it's the kind of implementation that makes one wonder how one is spending their life.
So far, everything about the XY API applies to Box and compounds. I don't think I could compose another layer on top of Box with the full variation of all the Num types and conversions as they really start ballooning and readability dwindles.
Here's a usage example:
// Make a signed 16b box starting at (10, 10) and ending at (490, 330),
// [(10, 10), (490, 330)].
const cam = I16Box(10, 10, 480, 320);
function toLevelXY(
point: Readonly<NumberXY>,
clientViewportWH: Readonly<NumberXY>,
cam: Readonly<I16Box>,
): I16XY {
return I16XY.trunc(
cam.start.x + (point.x / clientViewportWH.x) * I16Box.width(cam),
cam.start.y + (point.y / clientViewportWH.y) * I16Box.height(cam),
);
}
#Make
There's a new release of Make! It has the fix I've been waiting for! Who knows when Debian stable will get it but now it's inevitable.
#Next
As I inch along on the remaining Sublime Solitaire work, trying to build a friendly platform for efficient expression, I think about what's next. I'm really excited to rebuild Nature Elsewhere and / or Linear Text! I have everything I need except time.