luv

Workshop wiki

sky-and-light.org

Sky, atmosphere, and voxel light for the little block world

Status: implementation notes and remaining plan #SK7L2T

This page records the sky and lighting system for luvcraft: an animated sky and primary light, caves and overhangs which become dark for the right reason, light-emitting blocks, relighting after edits and residency changes, and a clean route to HDR bloom and directional shadows.

The important outcome of the design review is that shader-language growth is part of the work, not a cost to evade. Frame-wide facts still belong in Lisp; spatially varying image mathematics belongs in shaders; and the boundary between them should become more capable as the renderer asks more of it.

Phases 0--4 and the first directional-shadow pass now have implementations in the codebase, and the HDR presentation stack the last phase asked for has landed: a linear floating-point scene attachment, a filmic curve, a bloom chain, and light shafts (#IC14P3). The sky it presents became procedural image mathematics rather than a two-colour gradient (#9SSXDJ), and the block surface grew a shape of its own from what the mesher knows about each face's edges (#J19EBO) and what the atlas paints beside each material's colour (#CHUKWD). A later round took the shading itself toward physical argument: no two faces are the same card any more (#3AVEKC), the specular lobe and the ambient are microfacet and hemispheric rather than fitted (#2T8CCH), the sky became an atmosphere (#8X33G2), and the cloud deck became a plane both halves of the renderer can find (#RSGLTL). Each phase below has a visible result and an evidence gate, so it can be revised from measurements without undoing the preceding phases.

What the critique changed #SKCRIT

The first draft had a useful inventory and a good instinct for dense chunk data, immutable mesh snapshots, and last-known-good shader replacement. It also optimized too hard for making no new machinery. These are the changes which make the plan durable.

What exists today: verified touch points #SK9INV

The rendering model #SKRMOD

There are two related systems, not one.

  1. The environment is continuous and changes every frame: sky radiance, primary-light direction and colour, ambient colour, fog, exposure, and time of day. Lisp evaluates its small time/profile model once per frame and writes a uniform block. Sky and material shaders evaluate spatial image functions from those parameters.
  2. The voxel light field changes when block content or residency changes: sky access, propagated blocklight, opacity, and emission. It is a dense derived field associated with resident chunks, revised independently from block content, and sampled into immutable mesh snapshots.

The block fragment material starts from a linear-light equation rather than a blend trick. In schematic form:

let*
sky-level
light-responsesky-input
block-level
light-responseblock-input
n-dot-l
max0.0
dotnormalsun-direction

Lateral skylight gives ambient visibility but not a hard sun beam.

sun-visibility
smoothstep0.901.0sky-input
sky-light
*sky-colorsky-levelao
sun-light
*sun-colorn-dot-lsun-visibilityday-factor
local-light
*block-light-colorblock-level
reflected
*albedo
+sky-lightsun-lightlocal-light
radiance
+reflected
*emission-coloremission-input
fogged
mixradiancefog-colorfog-amount
set-outputcolor-output
vec4fogged1.0

The exact response curves and balances are art parameters, not baked mesh facts. Vertices carry normalized raw levels; shader edits can change the curve without remeshing the world. AO modulates diffuse sky access, not self-emission. When the HDR phase arrives, radiance above 1.0 survives until tone mapping and can feed bloom naturally.

Propagated skylight is not a directional shadow map. It makes caves dark and canopies reduce ambient sky access. A separate GPU depth pass now shadows the moving sun; vertical canopy shade remains ambient access, not an angled sun shadow.

Phase 0: give the shader language the mathematics this work needs #SKSHDR

Add the SPIR-V and expression-language capability before designing visual effects around its absence.

SPIR-V structure

  • Define OpExtInstImport and OpExtInst in the instruction vocabulary.
  • Give spir-v-module explicit extension and extended-instruction-import sections in SPIR-V logical-layout order. Imports are durable structured objects, not instruction forms inserted ad hoc by individual shaders.
  • Let lowering request GLSL.std.450 once when an operator needs it. Repeated operators in one module share the import ID; modules which use no extended math do not acquire one.
  • Preserve the bidirectional expression/instruction provenance used by the shader lab. An extended operation must still highlight as the instruction belonging to its source expression.

Typed operator vocabulary

The first useful family is min, max, clamp, smoothstep, abs, sqrt, expt, and normalize. Use CL's symbol where its meaning matches the compiled subset; use exported shader symbols for words CL does not have. Each operator gets explicit accepted scalar/vector signatures rather than a promise that every GLSL overload exists. The existing EQL-method protocol is the extension mechanism; a convenience definer may emit ordinary methods but must not hide a registry.

This phase does not add branching, loops, matrices, or every GLSL function pre-emptively. It establishes the import/lowering machinery and a coherent math baseline with immediate consumers. Later language growth remains a normal feature, not an architectural exception.

Uniform and vertex ABI checks

  • Keep vec4-lane uniform blocks for this phase: they are simple, aligned, and sufficient. Derive or assert the host byte size from the shader-visible layout so the 96-to-N-byte change is not duplicated magic.
  • Verify that the same frame uniform block can be declared in both vertex and fragment specifications at binding 2. Both stages must declare identical member order and offsets.
  • Use a fourth :float32x3 block vertex attribute for (sky block emission). Do not quietly write :float32x2 into the plan while the backend rejects it. General vertex formats remain a worthwhile GPU-API project when a consumer benefits from them.

Gate

Unit tests cover successful and rejected signatures, a single shared import, logical module ordering, deterministic lowering, and provenance. Generated modules pass spirv-val --target-env vulkan1.0. A live invalid edit preserves the old pipeline, and a corrected use of an extended operator installs on the running Vulkan path.

Phase 1: a real sky driven by a small Lisp clock #SKDYCY

A sky-clock belongs to the luvcraft session because it has mutable runtime identity worth inspecting: time, rate, paused state, and an optional pinned time for deterministic capture. A sky profile is ordinary data: keyframes for sky/horizon colour, primary-light colour, ambient colour, fog, and exposure. There is no class hierarchy or generic dispatch until more than one actual sky model exists.

sky-frame-parameters evaluates the clock and profile once per frame. CPU trigonometry is appropriate for a single sun direction; it is not a reason to move per-pixel sky shaping out of the shader. The frame uniform grows by aligned lanes for at least:

The three hardcoded sky literals have been replaced by this one frame result. The clear value is merely a safe fallback.

A live :sky pipeline draws a fullscreen triangle before block geometry in the existing scene pass, with depth writes disabled. Its fragment shader reconstructs or receives a view ray and uses normalize, smoothstep, max, and expt for a vertical gradient, horizon band, sun disc, and a soft forward glow. A tiny static fullscreen vertex buffer is acceptable; integer vertex-index support is not a prerequisite unless it is independently useful.

The block shader reads the same environment lanes directly. Fog becomes a clamped function with explicit near/far semantics. The sky pass itself is not fogged into its own colour.

The clock advances in render-luvcraft-frame from its bounded delta. SLY can pause it, set a time, change its rate, or replace the profile without restarting. Smoke capture pins it; interactive play animates it.

Gate

Noon, dusk, and night captures show a continuous sky, matching fog, and a moving primary light in the actual renderer. The shader lab can inspect the sky specifications. A fixed clock produces a byte-identical smoke image; unpinning it visibly advances without changing block content or remeshing.

Phase 2: make voxel light an explicit derived field #SKSTOR

Each resident block-chunk may own a chunk-light-field with:

The semantic light-field object belongs at the chunk boundary; its 4096-site columns remain dense arrays. Two u8 arrays cost 8 KiB for a 16-cubed chunk. Nibble packing is deferred until memory or bandwidth measurements say it matters.

Block light behavior is distinct from collision. Add both protocols now:

defgenericblock-light-opacity
block

0..15

defgenericblock-light-emission
block

0..15

defgenericblock-surface-emission
block

linear material intensity

Air answers zero for all three. A block-kind method reads configured slots with opaque/non-emissive defaults; particular kind instances carry the data. Propagation strength and visible surface radiance are separate even when one crystal configures both. The solver dispatches once per palette entry to build dense opacity and light-emission lookup vectors, then its hot loops index those vectors. It does not call a generic for every cell.

Light publication advances light revisions only. The mesh dependency stamp contains the target chunk's content and light revisions plus the opposing content and light boundary revisions of sampled neighbors. That preserves the reason a mesh became stale and avoids feedback in which derived light looks like another authored edit.

This field is a sibling materialization over the chunk's domain, not a second spatial domain merely because it has its own revisions. The current light-region nevertheless rebuilds world decomposition, dense offsets, and face enumeration, while meshing rebuilds related traversal for its resolved neighborhood and immutable halo. #WUB581 records that concrete duplication; #K3KZTG makes the shared domain/window vocabulary the next structural proof.

Lighting semantics and boundaries #SKBOUND

The little generated world can initially declare open sky above its known vertical extent and a closed lower boundary. A future vertically streamed source can provide sky-column knowledge or retain :unknown until the upper stack is known. That source protocol, rather than a hardcoded chunk Y=0 test, is the growth seam.

The solver: explicit reference truth plus one runtime implementation #SKRELT

Maintain two roles with the same semantics but deliberately different loader and dispatch status.

  1. A from-scratch reference relight clears a finite captured region, seeds known sky boundaries and emitters, imports explicit boundary values, and propagates to fixation. It favors obvious correctness and is loaded only by the explicit luvcraft/light-reference test/reference system.
  2. The runtime relighter maintains removal and addition queues. Content changes first recompute direct-sky seeds for affected columns. Decreased or invalidated values enter the removal queue; surviving sources and alternate paths enter the addition queue. The same mechanism crosses resident chunk faces and works for both edits and residency transitions.

The old 3x3-horizontal recomputation remains useful historical test evidence, not a current-world fallback. Production dispatch accepts only :compiled; unknown, misspelled, and retired solver names signal immediately. The reference system can still compare a complete captured world without placing the oracle on the operational recovery path.

A luvcraft-lighting-state owned by the session records dirty cell/column or chunk coordinates, residency-boundary changes, queue counters, and an in-progress candidate. World content hooks and residency publication feed that state; a dirty bit stored only on a chunk would lose departures and give too little information for future vertical columns.

Reconciliation runs before mesh snapshots are captured, but it does not expose half-solved arrays. Work is accumulated in candidate copies of touched light fields and published as one owner-thread transaction when the queues settle. Until then meshes continue to use the previous stable revision. A new edit either merges into or invalidates the candidate explicitly.

Start with bounded owner-thread work and record cells popped, chunks touched, milliseconds, restarts, and edit-to-publish latency. If real captures show hitches, capture immutable block/light/boundary inputs in a new production request and publish a complete candidate through the existing perform-production-request / publish-production-result protocol. Its dependency stamp rejects stale results. Do not add a second worker system.

Meshing carries raw light, not an art-directed bake #SKMESH

make-block-mesh-snapshot copies sky and block u8 columns for the same one-cell halo as block samples, after stable lighting publication. A sample-light-at generic is justified at this representation boundary: the live world/neighborhood and immutable snapshot each provide one method, while the dense inner representation remains arrays.

Corner lighting samples the face-adjacent air cells around each vertex. It may share the existing AO traversal after a test establishes the intended sample coordinates, but AO occupancy and light averaging remain separately named results. Occluded samples and absent/unknown samples have explicit rules rather than falling through block-solid-p.

The mesh adds one attribute at location 3:

light-material-input :vec3 = (sky-level / 15,
                              block-level / 15,
                              surface emission)

That widens the current 36-byte vertex to four :float32x3 lanes and a 48-byte stride, which the backend can describe today. It is intentionally plain for the first proof. Record generated vertex bytes and mesh time; if bandwidth becomes material, add normalized packed vertex formats to the GPU API as a measured optimization rather than obscuring the initial format.

Light revision changes schedule remeshing through the existing snapshot and stale-result machinery. Relit visible chunks receive priority; an artificially slow “pleasant ripple” is not an acceptance criterion. Measure solver completion and mesh publication separately so either bottleneck is visible.

Gate

Caves, overhangs, and canopy shade work across chunk seams. Arrival and eviction do not leave stale bright or dark borders. A vertical multi-chunk test world works even though the current generator resides at Y=0. Random edit and residency sequences leave the runtime field bit-identical to the reference relight after queues settle.

Meshing also carries what a fragment cannot see #J19EBO

#SKMESH establishes why light readings belong in the vertex product: they are what the mesher knows and the fragment stage does not. Face edges are the same kind of fact, and they turn out to be the difference between a block world which reads as carved solids and one which reads as a lit grid.

A block face is flat geometry, but a real surface rounds over where it ends and fillets where it meets a wall. Perturbing the shading normal near the face's boundary gives both, and needs no mesh change beyond knowing where the fragment sits inside its cell. Doing it at every boundary, though, draws a seam across the middle of an open plain, where the face merely continues into an identically oriented neighbour and there is no edge at all. A fragment cannot tell those cases apart; the mesher can.

So block-face-edge-shaping-components classifies each face's four in-plane edges once, from the same neighbourhood the corner occlusion already samples:

classificationwhat lies across the edgewhat the surface does
concave (-1)a block rises at the edgefillet inward, gather occlusion
flush (0)the same plane continuesnothing
convex (+1)nothinground over

The four signed values become a fifth vertex lane, :float32x4, and the fragment stage multiplies each of its four boundary ramps by the matching sign. One expression therefore serves all three cases, and the sign is what turns a round-over into a fillet. The two in-plane axes are chosen from the face normal by the same rule on both sides of the ABI, which is what makes U and V mean the same thing to the mesher and to the shader.

This widened the vertex from a 48-byte to a 64-byte stride, and required the portable vertex-attribute vocabulary to grow past :float32x3 alone --- a gap #SKMESH's "four :float32x3 lanes" had left standing in both backends.

Two later corrections, both about making the round-over read as a shape (#YUB8CH).

A round-over is an arc, not a leaning plane #YUB8CH

The first version of #J19EBO's shaping added a scaled tangent to the face normal and normalized the sum. That can only ever reach the arctangent of the scale --- a strength of 0.42 buys 23 degrees --- so a legible edge needs an implausible scale, and even then the shading bunches into the last sliver of the ramp because the tangent grows linearly while the angle saturates.

Rotating the normal through an angle instead sweeps it evenly from the face toward the edge, which is what a fillet of a given radius actually does. The in-plane lean gives the direction and the ramp gives the fraction of a quarter turn, and the normal is the corresponding point on that arc. The guarded denominator matters more than it looks: over the flat middle of a face the lean is exactly zero, so the direction is undefined and only the guard keeps it from being a division by zero --- but the angle is zero there too, so the undefined direction is multiplied away.

The second correction is that a round-over is only legible if it is a few pixels wide. Held to a fixed fraction of a cell it dwindles as blocks recede, and everything in the middle distance flattens back into a card long before it is genuinely unresolvable. The block material already measures its own texel footprint from screen derivatives of the atlas coordinate, for the relief fade; the same measurement grows the bevel width until the edge is worth drawing, bounded above so it never eats the face it is an edge of, and then fades it out. So the two shapings share one measurement and use it in opposite directions: relief shrinks with distance, the round-over widens.

The atlas paints relief as well as colour #CHUKWD

Colour is only half of what a material looks like. The other half is its micro-surface: whether it is granular, grooved, tufted, or faceted. luvcraft's atlas was already generated arithmetic rather than an asset, one paint-block-atlas-tile method per numbered tile, so the honest place for that half is the same arithmetic. paint-block-atlas-relief paints a height field; colour variation reads that field, and make-block-normal-atlas materializes its tile-clamped central difference as a linear tangent-space normal map. The block material reads that map once instead of reconstructing the gradient with four dependent texture taps per fragment.

The colour atlas stays sRGB with ordinary opaque coverage. The normal atlas is linear RGBA8: RGB is the encoded unit normal and alpha retains the source height for inspection. The shader declares those sampled meanings as :surface-normal-sample and :surface-relief rather than treating channels as unlabeled numbers. #SKOPEN's decision that bloom must not commandeer scene alpha remains independent of both material textures.

Relief and bevel are both sub-block detail, so both fade out on a measured texel footprint from the atlas UV derivatives: distant terrain neither sparkles nor shows a lit grid. A material's relief is one EQL method, so a live image can re-sculpt grass tufts or stone cracks and rebuild both dense atlases without touching the rest of the palette.

defgeneric paint-block-atlas-relief blocks.lisp:362
defgenericpaint-block-atlas-relief
tilexy
:documentation

Return the 0..255 surface height of atlas tile tile at tile-local X,Y.

Like the colour, each named tile is one EQL method, so a live image can re-sculpt a single material's micro-surface and rebuild both atlases without touching the rest of the palette.

See #CHUKWD for why relief and normals are generated beside colour rather than arriving as authored assets.

No two faces are the same card #3AVEKC

The atlas has one tile per material and the world has thousands of faces per tile, so #CHUKWD's relief only made every grass block the same card seen again in better light. Three fields now make each face its own, and none of them costs a texel.

A hash of the cell a face belongs to is constant across that face and different on the next one. The cell is found by stepping half a cell back along the face's own normal, which lands inside the block whichever of the six faces this is. Value noise over the world point knows nothing about where faces begin, so it drifts across a whole plain the way ground does, at a scale of about a dozen cells. A grain finer than a cell supplies the last octave, and --- the part that matters --- two more taps of that grain, a step along each of the face's own in-plane axes, give its gradient there, which leans the shading normal. The ground is then not merely painted unevenly but lit unevenly, which is what the eye actually reads as texture.

Value and hue move together but not identically: brighter patches are also warmer, and a face's own constant drifts its hue as well as its value, so a plain of grass is many greens rather than one green at many brightnesses. Every scale fades out on the footprint that scale resolves at --- the texel-scale terms on the relief fade, the cell-scale ones on the bevel fade of #YUB8CH --- and all three amplitudes ride one surface-detail knob.

define-shader-method shader-specification-for shaders.lisp:1218
define-shader-methodshader-specification-forblock-world-fragment-specification
role
eql:block-surface
stage
eql:fragment
:stage:fragment:inputs
uv-shade-input:vec3:location0:components
:xy:quantity:texture-uv:unit:one
:z:quantity:ambient-occlusion:unit:one
normal-input:vec3:location1:quantity:world-direction:unit:one
fog-input:float:location2:quantity:fog-amount:unit:one
light-input:vec3:location3:components
:x:quantity:sky-light-level:unit:one
:y:quantity:block-light-level:unit:one
:z:quantity:material-emission:unit:one
shadow-uv-input:vec2:location4:quantity:shadow-uv:unit:one
shadow-depth-input:float:location5:quantity:shadow-depth:unit:one
world-position-input:vec3:location6:quantity:world-position:unit:cell
edge-shaping-input:vec4:location7
tile-offset-input:float:location8:quantity:atlas-tile-offset:unit:one:interpolation:flat
:outputs
color-output:vec4:location0
:resources
block-atlas:texture-2d:set0:binding0:sample-transfer:srgb-to-linear:sample-components
:rgb:quantity:linear-rgb:unit:one
block-sampler:sampler:set0:binding1
frame-state:uniform-block:set0:binding2:members#.*frame-uniform-members*
shadow-map:depth-texture-2d:set0:binding3:sample-components
:x:quantity:shadow-depth:unit:one
shadow-sampler:sampler:set0:binding4
shadow-comparison-sampler:sampler:set0:binding5
block-normal-atlas:texture-2d:set0:binding6:sample-transfer:identity:sample-components
:rgb:quantity:surface-normal-sample:unit:one
:a:quantity:surface-relief:unit:one
let*
uv-shadeuv-shade-input
uv
swizzleuv-shade:xy
ao
swizzleuv-shade:z
normalnormal-input

--- the shaped surface ------------------------------------------- A block face is flat geometry, but it should not read as a flat material. Two shapings ride on the same face: a bevel that rounds the last sixth of a cell toward its edge, and a per-texel relief read straight out of the atlas the material is already painted in. Both are pure normal perturbation, so no mesh changes.

flat-normal
representationnormal-input
surface-point
representationworld-position-input
eye
representation
swizzlecamera-vector:xyz
cell
fractsurface-point
axis-x
abs
swizzleflat-normal:x
axis-y
abs
swizzleflat-normal:y

A block face's own normal selects its two in-plane world axes, so one expression serves all six faces without a branch.

tangent-u
vec3
-1.0axis-x
0.0axis-x
tangent-v
vec30.0
-1.0axis-y
axis-y
plane-u
plane-v

The atlas coordinate and its screen derivative size both shapings. The normal itself is already the central difference of the same procedural relief field that paints the colour atlas.

tile-count11.0
tile-texels16.0

Texels per pixel: one cell is TILE-TEXELS of them.

footprint
max
*
*tile-counttile-texels
*tile-texels

Both shapings are sub-block detail, so both have to fade out as soon as they stop resolving on screen; otherwise distant terrain sparkles and shows a lit grid instead of a surface. One measured footprint drives both, at their own scales.

relief-fade
-1.0
smoothstep0.301.10footprint
bevel-fade
-1.0
smoothstep1.605.00footprint

The mesher's edge classification decides what each boundary does. A convex edge rounds the surface over; a concave one fillets it into the inner corner; a flush one -- the middle of an open plain, where the face simply continues into an identically oriented neighbour -- leaves the surface alone. Signs come straight from the vertex lane, so the same ramp expression serves all three.

edgeedge-shaping-input
edge-u-low
swizzleedge:x
edge-u-high
swizzleedge:y
edge-v-low
swizzleedge:z
edge-v-high
swizzleedge:w

A round-over is only legible if it is a few pixels wide. Held to a fixed fraction of a cell it dwindles to nothing a short way off, and every block in the middle distance flattens back into a card; grown with the footprint it keeps its shape until it is genuinely too small to resolve. The upper bound stops it from eating the face it is supposed to be an edge of.

bevel-width
clamp
*0.115footprint
0.1050.185
ramp-u-low
smoothstepbevel-width0.0plane-u
ramp-u-high
smoothstep
-1.0bevel-width
1.0plane-u
ramp-v-low
smoothstepbevel-width0.0plane-v
ramp-v-high
smoothstep
-1.0bevel-width
1.0plane-v
bevel-u
-
*ramp-u-highedge-u-high
*ramp-u-lowedge-u-low
bevel-v
-
*ramp-v-highedge-v-high
*ramp-v-lowedge-v-low
bevel-lean
+
*tangent-ubevel-u
*tangent-vbevel-v

An inner corner is a crevice, and a crevice gathers occlusion. Only the filleted edges contribute; a rounded-over outer edge is more exposed than the face it belongs to, not less.

crease
+
*ramp-u-low
max0.0
-edge-u-low
*ramp-u-high
max0.0
-edge-u-high
*ramp-v-low
max0.0
-edge-v-low
*ramp-v-high
max0.0
-edge-v-high
seam
clampcrease0.01.0

One linear texture read replaces the four height taps formerly used here. The CPU generator clamps those taps within each tile, normalizes the result, and stores the source height in alpha for inspection. Fade the decoded tangent normal back toward the flat face as its texels become sub-pixel.

normal-sample
representation
swizzle
sampleblock-normal-atlasblock-sampleruv
:rgb
tangent-normal
-
*normal-sample2.0
vec31.01.01.0
relief-x
*
swizzletangent-normal:x
relief-fade
relief-y
*
swizzletangent-normal:y
relief-fade
relief-z
mix1.0
swizzletangent-normal:z
relief-fade

A real round-over is an arc, not a leaning plane. Adding a scaled tangent to the face normal can only ever reach the arctangent of that scale, so a strong edge needs an implausible scale and still bunches its shading into the last sliver. Rotating the normal through an angle instead sweeps it evenly from the face toward the edge, which is what a fillet of that radius actually does. The guarded denominator matters: over the flat middle of a face the lean is exactly zero, and the angle is zero with it.

lean-magnitude
sqrt
dotbevel-leanbevel-lean
lean-direction
/bevel-lean
max0.0001lean-magnitude
bevel-turn
*
min1.0lean-magnitude
*0.90bevel-fade
rounded
+
*flat-normal
cosbevel-turn
*lean-direction
sinbevel-turn
seam-occlusion
-1.0
*0.34
*seambevel-fade

--- the weathering ----------------------------------------------- The atlas has one tile per material and the world has thousands of faces per tile, so on its own every grass block is the same card seen again. Three fields fix that and none of them costs a texel: a hash of the cell a face belongs to, constant across that face and different on the next one; value noise over the world point itself, which knows nothing about where faces begin and so drifts across a whole plain the way real ground does; and a grain finer than a cell whose own gradient leans the surface, so the ground is not merely painted unevenly but lit unevenly. The cell is found by stepping half a cell back along the face's own normal, which lands inside the block whichever of the six faces this is. #3AVEKC

face-cell
floor
-surface-point
*flat-normal0.5
face-seed
lattice-hash
+face-cell
*flat-normal0.37
face-hue
lattice-hash
+face-cell
vec35.211.379.13
patch
lattice-fractal-noise
*surface-point0.075
grain-point
*surface-point1.05
grain
lattice-noisegrain-point

Two more taps of the same grain, a step along each of the face's own in-plane axes: their differences are the field's gradient there, which is exactly the tilt a bump of it would give.

grain-u
lattice-noise
+grain-point
*tangent-u0.55
grain-v
lattice-noise
+grain-point
*tangent-v0.55
weathering
clamp
*surface-detail
+
*
-patch0.5
1.05
+
*
-face-seed0.5
0.30
*
-grain0.5
*0.30bevel-fade
-0.420.42

Brighter patches are also warmer and duller ones cooler, and a face's own constant drifts its hue as well as its value: a plain of grass wants to be many greens, not one green at many brightnesses.

hue-drift
clamp
*surface-detail
+
*
-face-hue0.5
0.36
*
-patch0.5
0.44
-0.350.35
weathered-tint
+
vec31.01.01.0
+
*
vec31.121.00.84
weathering
*
vec30.130.02-0.11
hue-drift

The grain is a cell-wide feature, so it survives to the distance a cell does rather than the distance a texel does.

bump-strength
*0.85
*bevel-fadesurface-detail
shaped
normalize
+
+
*roundedrelief-z
*tangent-u
+relief-x
*
-graingrain-u
bump-strength
*tangent-v
+relief-y
*
-graingrain-v
bump-strength
shading-normal
assume-quantityshaped:quantity:world-direction:unit:one

--- the micro-surface -------------------------------------------- The atlas normal is this material's own slope at texel scale, and the best evidence available about what happens below a texel: a steep texel is a rough material. When the relief fades out with distance its variance has to go somewhere, and the honest place is the roughness -- Toksvig's argument -- so a highlight keeps its size across the draw distance instead of sharpening into a sparkle on every distant face at once. #2T8CCH

relief-slope
sqrt
+
*
swizzletangent-normal:x
swizzletangent-normal:x
*
swizzletangent-normal:y
swizzletangent-normal:y
roughness
clamp
*surface-roughness
+
mix0.400.92
clamp
*relief-slope1.7
0.01.0
+
*0.28
-1.0relief-fade
*0.10
-grain0.5
0.160.99
sun-direction
swizzlesun-vector:xyz
n-dot-l
max0.0
dotshading-normalsun-direction
shadow-coordinateshadow-uv-input
shadow-u
swizzleshadow-coordinate:x
shadow-v
swizzleshadow-coordinate:y
shadow-in-bounds
*
step
quantity0.0:quantity:shadow-u:unit:one
shadow-u
stepshadow-u
quantity1.0:quantity:shadow-u:unit:one
step
quantity0.0:quantity:shadow-v:unit:one
shadow-v
stepshadow-v
quantity1.0:quantity:shadow-v:unit:one
shadow-texel-size
swizzleshadow-control-vector:xy
shadow-base-bias
swizzleshadow-control-vector:z
shadow-slope-bias
swizzleshadow-control-vector:w

A PCF tap displaced across a receiver plane must compare against that plane's depth at the tap, not the center fragment depth. With the orthographic shadow rows, the analytical UV depth gradient is the face normal expressed in the light basis and scaled by the world-span/depth-span ratio. Its denominator is bounded only near grazing incidence, where direct sun is negligible. This prevents wide kernels from reading the receiver's own slope as an occluder.

shadow-right
assume-quantity
normalize
swizzleshadow-row-x:xyz
:quantity:world-direction:unit:one
shadow-up
assume-quantity
normalize
swizzleshadow-row-y:xyz
:quantity:world-direction:unit:one
shadow-forward
assume-quantity
normalize
swizzleshadow-row-z:xyz
:quantity:world-direction:unit:one
shadow-normal-forward
min-0.05
dotnormalshadow-forward
shadow-depth-span
swizzleshadow-filter-vector:x
shadow-world-units-per-texel
swizzleshadow-filter-vector:y
shadow-world-span
interpret
/shadow-world-units-per-texel
swizzleshadow-texel-size:x
:quantity:world-distance:unit:cell
shadow-span-ratio
/shadow-world-spanshadow-depth-span
shadow-depth-gradient
interpret
vec2
-
assume-quantity
representation
*
/
dotnormalshadow-right
shadow-normal-forward
shadow-span-ratio
:quantity:shadow-depth-gradient:unit:one
-
assume-quantity
representation
*
/
dotnormalshadow-up
shadow-normal-forward
shadow-span-ratio
:quantity:shadow-depth-gradient:unit:one
:quantity:shadow-depth-gradient:unit:one
shadow-center-depth
swizzle
sampleshadow-mapshadow-samplershadow-coordinate
:x
receiver-depthshadow-depth-input
shadow-blocker-separation
interpret
max
quantity0.0:quantity:shadow-depth:unit:one:character:difference
-
-receiver-depthshadow-bias
shadow-center-depth
:quantity:shadow-depth:unit:one:character:absolute
shadow-minimum-radius
swizzleshadow-filter-vector:z
shadow-maximum-radius
swizzleshadow-filter-vector:w
sun-angular-width
swizzlesun-color-vector:w
shadow-penumbra-world-radius
interpret
*
*shadow-blocker-separationshadow-depth-span
sun-angular-width
:quantity:world-distance:unit:cell
shadow-filter-radius
clamp
+shadow-minimum-radius
interpret
/shadow-penumbra-world-radiusshadow-world-units-per-texel
:quantity:shadow-filter-radius:unit:one
shadow-minimum-radiusshadow-maximum-radius
shadow-sample
shadow-visibilityshadow-mapshadow-comparison-samplershadow-coordinatereceiver-depthshadow-depth-gradientshadow-texel-sizeshadow-biasshadow-filter-radius
sampled-shadow
mix1.0shadow-sampleshadow-in-bounds

A surface turned away from the light has no shadow decision to make: its own geometry already excludes the sun, the receiver plane's depth runs almost parallel to the light so every filter tap lands a large and increasingly ill-conditioned distance away, and the map's answer degenerates into noise. Near noon that is every vertical face in the world at once, which is why the shimmer was worst there. Fading the decision out toward "lit" exactly where it stops meaning anything costs nothing visually -- the same cosine multiplies the direct term to zero -- and gives the specular lobe and the diagnostic a stable value instead of the noise.

The lower edge is where the receiver-plane denominator above starts being clamped: SHADOW-FORWARD is the negated sun, so that dot product is exactly -N-DOT-L, and the clamp engages precisely below 0.05. The two mechanisms therefore hand over rather than overlap, and the clamp never has to carry a visible decision.

shadow-relevance
smoothstep0.050.22n-dot-l
direct-shadow
mix1.0sampled-shadowshadow-relevance

The mesh carries normalized raw light readings; every response curve and balance below is an art parameter editable live without remeshing the world.

sky-input
swizzlelight-input:x
block-input
swizzlelight-input:y
emission-input
swizzlelight-input:z
sky-level
*sky-inputsky-input
block-level
*block-inputblock-input
day-factor
swizzlesun-vector:w

Lateral skylight gives ambient visibility but not a hard sun beam; the shadow map gates only the direct solar term.

sun-visibility
smoothstep
quantity0.90:quantity:sky-light-level:unit:one
quantity1.0:quantity:sky-light-level:unit:one
sky-input
ambient
swizzleambient-vector:xyz
sun-color
swizzlesun-color-vector:xyz

--- the deck's shadow -------------------------------------------- The sky draws a cumulus deck on a plane at a fixed world height, and the ground under it should know. Following the sun up from this point to that plane and asking the very same field the very same coverage question is the whole of it: the deck's own shadow sweeping across the world, which is what keeps a plain at noon from being one flat sheet of light. The threshold is wider than the sky's, because a shadow thrown from that far off is blurred by the sun's own angular width long before it lands. #RSGLTL

elapsed
sun-climb
max0.15
representation
swizzlesun-direction:y
deck-reach
/
max8.0
-cloud-altitude
swizzlesurface-point:y
sun-climb
deck-meeting
+
+surface-point
*
representationsun-direction
deck-reach
vec3
*elapsed2.2
0.0
*elapsed1.2
deck-cover
cloud-fractal-noise
*deck-meeting0.0044
deck-coverage
cloud-shadow
assume-quantity
-1.0
*cloud-shadow-depth
smoothstepdeck-coverage
+deck-coverage0.20
deck-cover
:quantity:cloud-shadow:unit:one

Occlusion bites harder than the raw mesh reading: the corner where three blocks meet is what tells the eye these are solid volumes.

occlusion
interpret
*
exptao1.8
assume-quantityseam-occlusion:quantity:ambient-occlusion:unit:one
:quantity:ambient-occlusion:unit:one

--- what is not the sun ------------------------------------------ Everything above a face that is not the sun is still the sky, and the sky is not one colour: a face turned up sees the zenith, a face turned sideways sees the horizon, and a face turned down sees what the ground bounced. One interpolation over the normal's own vertical component is the whole of it, and it is the difference between blocks standing in a world and blocks floating in a grey room. The profile's ambient colour stays in the mixture: it is the art direction's say over light the geometry cannot explain.

facing-up
*0.5
+1.0
representation
swizzleshading-normal:y
dome-color
mix
representation
swizzlehorizon-vector:xyz
representation
swizzlezenith-vector:xyz
*facing-upfacing-up
bounce-color
environment
assume-quantity
mixbounce-color
mixdome-color0.62
facing-up
:quantity:linear-rgb:unit:one

A small floor keeps unlit geometry barely readable rather than a void; caves stay dark for the right reason. The ambient term is deliberately weaker, and the sun correspondingly stronger, than a display-referred renderer could afford: the filmic curve on presentation is what brings the sunlit half back down.

sky-light
interpret
*environment
+0.030
*0.86sky-level
occlusion
:quantity:linear-rgb:unit:one
sun-light
interpret
*sun-color
*direct-light-gainn-dot-lsun-visibilityday-factordirect-shadowcloud-shadow
mix0.551.0ao
:quantity:linear-rgb:unit:one
torch-color
quantity
vec31.00.820.58
:quantity:linear-rgb:unit:one
local-light
interpret
*torch-colorblock-level
:quantity:linear-rgb:unit:one
albedo
assume-quantity
*
representation
swizzle
sampleblock-atlasblock-sampleruv
:rgb
weathered-tint
:quantity:linear-rgb:unit:one
reflected
interpret
*albedo
+sky-lightsun-lightlocal-light
:quantity:linear-rgb:unit:one

--- the microfacet lobe ------------------------------------------ One GGX lobe off the shaped normal, with Smith's height-correlated visibility and a dielectric's Fresnel: blocks are not metal, so four per cent at normal incidence and the whole of it at a grazing one. The roughness above decides everything about its shape, so a polished face and a tufted one differ here without differing anywhere else.

view-direction
assume-quantity
normalize
-eyesurface-point
:quantity:world-direction:unit:one
half-vector
assume-quantity
normalize
+
normalize
-eyesurface-point
representationsun-direction
:quantity:world-direction:unit:one
n-dot-h
max0.0
dotshading-normalhalf-vector
n-dot-v
max0.0
dotshading-normalview-direction
v-dot-h
max0.0
dotview-directionhalf-vector
cosine-light
cosine-view
cosine-half
alpha
*roughnessroughness
alpha-squared
*alphaalpha
distribution-denominator
+
*
*cosine-halfcosine-half
-alpha-squared1.0
1.0
distribution
/alpha-squared
max0.0001
*3.14159265
*distribution-denominatordistribution-denominator

Smith's height-correlated visibility already carries the 1/(4 cos cos) the microfacet specular would otherwise divide by.

visibility-light
*cosine-view
sqrt
+
*
*cosine-lightcosine-light
-1.0alpha-squared
alpha-squared
visibility-view
*cosine-light
sqrt
+
*
*cosine-viewcosine-view
-1.0alpha-squared
alpha-squared
visibility
/0.5
max0.0001
+visibility-lightvisibility-view
fresnel
+0.04
*0.96
expt
-1.0
5.0

The cosine belongs in the specular lobe as much as in the diffuse one: without it a highlight can appear on a face the sun cannot reach, carrying whatever the shadow map happened to say there.

sun-specular
*
*
*distribution
*visibilityfresnel

The same sky again, in the direction the surface actually reflects: the sheen that tells a smooth face from a rough one out of the sun, and the one term that makes a block look like it is standing under this weather rather than under a light bulb.

reflection-direction
-
*
representationshading-normal
*2.0cosine-view
representationview-direction
reflection-up
clamp
*0.5
+1.0
swizzlereflection-direction:y
0.01.0
reflected-sky
mix
representation
swizzlehorizon-vector:xyz
representation
swizzlezenith-vector:xyz
*reflection-upreflection-up
sheen
*
-1.0roughness
+0.035
*0.32
expt
-1.0cosine-view
5.0
ambient-specular
*reflected-sky
*sheen
specular
assume-quantity
+sun-specularambient-specular
:quantity:linear-rgb:unit:one
radiance
+reflectedspecular
interpret
*albedoemission-input
:quantity:linear-rgb:unit:one

Distant terrain fades into exactly the colour the sky's own ground half arrives at, warmed where the ray runs toward a low sun: the aerial perspective that puts a mile of air between here and the horizon.

look-direction
normalize
-surface-pointeye
sun-elevation
representation
swizzlesun-direction:y
low-sun
*
-1.0
smoothstep0.020.45sun-elevation
fog-color
assume-quantity:quantity:linear-rgb:unit:one
fog-amountfog-input
fogged
mixradiancefog-colorfog-amount
normal-rgba
assume-quantity
vec4
representation
quantity1.0:quantity:opacity:unit:one
:quantity:linear-rgba:unit:one
shadow-diagnostic
swizzlefog-color-vector:w
shadow-rgba
assume-quantity
vec4
representation
vec3direct-shadowdirect-shadowdirect-shadow
representation
quantity1.0:quantity:opacity:unit:one
:quantity:linear-rgba:unit:one
rgba
mixnormal-rgbashadow-rgbashadow-diagnostic
set-outputcolor-outputrgba

A roughness the relief already knew #2T8CCH

The block material's specular was one fitted power under a Fresnel weight. It is an ordinary microfacet lobe now --- GGX distribution, Smith's height-correlated visibility in the form that already carries the 1/(4\cos\theta_l\cos\theta_v), Schlick's Fresnel at a dielectric's four per cent --- which would be unremarkable except for where its roughness comes from.

Nothing in the palette declares a roughness. But #CHUKWD's normal atlas is each material's own slope at texel scale, and a steep texel is the best evidence available about what happens below a texel: grass tufts are rough, a glass pane is smooth, and the atlas already knows which is which. So the roughness is read from the tangent normal's tilt. When that relief fades out with distance its variance has to go somewhere, and the honest place is the same roughness --- Toksvig's argument --- so a highlight keeps its size across the draw distance instead of sharpening into a sparkle on every distant face at once.

What is not the sun also stopped being one grey. A face turned up sees the zenith, a face turned sideways the horizon, and a face turned down what the ground bounced; one interpolation over the normal's own vertical component, against the zenith and horizon colours the frame environment already carries for the sky, is the whole of it. The same sky evaluated in the direction the surface actually reflects, weighted by roughness and a grazing Fresnel, gives the sheen that tells a smooth face from a rough one out of the sun. Those two terms are the difference between blocks standing under this weather and blocks standing under a light bulb.

One thing had to move that is not shading at all. The palette was authored display-referred, before the renderer worked in linear radiance, and sand at 0.59 linear reflects more light than fresh snow does. A microfacet lobe over an albedo that bright is a white card with a highlight on it, so the natural materials came down to the range a real one occupies and the exposure came down with them.

define-shader-method shader-specification-for shaders.lisp:1218
define-shader-methodshader-specification-forblock-world-fragment-specification
role
eql:block-surface
stage
eql:fragment
:stage:fragment:inputs
uv-shade-input:vec3:location0:components
:xy:quantity:texture-uv:unit:one
:z:quantity:ambient-occlusion:unit:one
normal-input:vec3:location1:quantity:world-direction:unit:one
fog-input:float:location2:quantity:fog-amount:unit:one
light-input:vec3:location3:components
:x:quantity:sky-light-level:unit:one
:y:quantity:block-light-level:unit:one
:z:quantity:material-emission:unit:one
shadow-uv-input:vec2:location4:quantity:shadow-uv:unit:one
shadow-depth-input:float:location5:quantity:shadow-depth:unit:one
world-position-input:vec3:location6:quantity:world-position:unit:cell
edge-shaping-input:vec4:location7
tile-offset-input:float:location8:quantity:atlas-tile-offset:unit:one:interpolation:flat
:outputs
color-output:vec4:location0
:resources
block-atlas:texture-2d:set0:binding0:sample-transfer:srgb-to-linear:sample-components
:rgb:quantity:linear-rgb:unit:one
block-sampler:sampler:set0:binding1
frame-state:uniform-block:set0:binding2:members#.*frame-uniform-members*
shadow-map:depth-texture-2d:set0:binding3:sample-components
:x:quantity:shadow-depth:unit:one
shadow-sampler:sampler:set0:binding4
shadow-comparison-sampler:sampler:set0:binding5
block-normal-atlas:texture-2d:set0:binding6:sample-transfer:identity:sample-components
:rgb:quantity:surface-normal-sample:unit:one
:a:quantity:surface-relief:unit:one
let*
uv-shadeuv-shade-input
uv
swizzleuv-shade:xy
ao
swizzleuv-shade:z
normalnormal-input

--- the shaped surface ------------------------------------------- A block face is flat geometry, but it should not read as a flat material. Two shapings ride on the same face: a bevel that rounds the last sixth of a cell toward its edge, and a per-texel relief read straight out of the atlas the material is already painted in. Both are pure normal perturbation, so no mesh changes.

flat-normal
representationnormal-input
surface-point
representationworld-position-input
eye
representation
swizzlecamera-vector:xyz
cell
fractsurface-point
axis-x
abs
swizzleflat-normal:x
axis-y
abs
swizzleflat-normal:y

A block face's own normal selects its two in-plane world axes, so one expression serves all six faces without a branch.

tangent-u
vec3
-1.0axis-x
0.0axis-x
tangent-v
vec30.0
-1.0axis-y
axis-y
plane-u
plane-v

The atlas coordinate and its screen derivative size both shapings. The normal itself is already the central difference of the same procedural relief field that paints the colour atlas.

tile-count11.0
tile-texels16.0

Texels per pixel: one cell is TILE-TEXELS of them.

footprint
max
*
*tile-counttile-texels
*tile-texels

Both shapings are sub-block detail, so both have to fade out as soon as they stop resolving on screen; otherwise distant terrain sparkles and shows a lit grid instead of a surface. One measured footprint drives both, at their own scales.

relief-fade
-1.0
smoothstep0.301.10footprint
bevel-fade
-1.0
smoothstep1.605.00footprint

The mesher's edge classification decides what each boundary does. A convex edge rounds the surface over; a concave one fillets it into the inner corner; a flush one -- the middle of an open plain, where the face simply continues into an identically oriented neighbour -- leaves the surface alone. Signs come straight from the vertex lane, so the same ramp expression serves all three.

edgeedge-shaping-input
edge-u-low
swizzleedge:x
edge-u-high
swizzleedge:y
edge-v-low
swizzleedge:z
edge-v-high
swizzleedge:w

A round-over is only legible if it is a few pixels wide. Held to a fixed fraction of a cell it dwindles to nothing a short way off, and every block in the middle distance flattens back into a card; grown with the footprint it keeps its shape until it is genuinely too small to resolve. The upper bound stops it from eating the face it is supposed to be an edge of.

bevel-width
clamp
*0.115footprint
0.1050.185
ramp-u-low
smoothstepbevel-width0.0plane-u
ramp-u-high
smoothstep
-1.0bevel-width
1.0plane-u
ramp-v-low
smoothstepbevel-width0.0plane-v
ramp-v-high
smoothstep
-1.0bevel-width
1.0plane-v
bevel-u
-
*ramp-u-highedge-u-high
*ramp-u-lowedge-u-low
bevel-v
-
*ramp-v-highedge-v-high
*ramp-v-lowedge-v-low
bevel-lean
+
*tangent-ubevel-u
*tangent-vbevel-v

An inner corner is a crevice, and a crevice gathers occlusion. Only the filleted edges contribute; a rounded-over outer edge is more exposed than the face it belongs to, not less.

crease
+
*ramp-u-low
max0.0
-edge-u-low
*ramp-u-high
max0.0
-edge-u-high
*ramp-v-low
max0.0
-edge-v-low
*ramp-v-high
max0.0
-edge-v-high
seam
clampcrease0.01.0

One linear texture read replaces the four height taps formerly used here. The CPU generator clamps those taps within each tile, normalizes the result, and stores the source height in alpha for inspection. Fade the decoded tangent normal back toward the flat face as its texels become sub-pixel.

normal-sample
representation
swizzle
sampleblock-normal-atlasblock-sampleruv
:rgb
tangent-normal
-
*normal-sample2.0
vec31.01.01.0
relief-x
*
swizzletangent-normal:x
relief-fade
relief-y
*
swizzletangent-normal:y
relief-fade
relief-z
mix1.0
swizzletangent-normal:z
relief-fade

A real round-over is an arc, not a leaning plane. Adding a scaled tangent to the face normal can only ever reach the arctangent of that scale, so a strong edge needs an implausible scale and still bunches its shading into the last sliver. Rotating the normal through an angle instead sweeps it evenly from the face toward the edge, which is what a fillet of that radius actually does. The guarded denominator matters: over the flat middle of a face the lean is exactly zero, and the angle is zero with it.

lean-magnitude
sqrt
dotbevel-leanbevel-lean
lean-direction
/bevel-lean
max0.0001lean-magnitude
bevel-turn
*
min1.0lean-magnitude
*0.90bevel-fade
rounded
+
*flat-normal
cosbevel-turn
*lean-direction
sinbevel-turn
seam-occlusion
-1.0
*0.34
*seambevel-fade

--- the weathering ----------------------------------------------- The atlas has one tile per material and the world has thousands of faces per tile, so on its own every grass block is the same card seen again. Three fields fix that and none of them costs a texel: a hash of the cell a face belongs to, constant across that face and different on the next one; value noise over the world point itself, which knows nothing about where faces begin and so drifts across a whole plain the way real ground does; and a grain finer than a cell whose own gradient leans the surface, so the ground is not merely painted unevenly but lit unevenly. The cell is found by stepping half a cell back along the face's own normal, which lands inside the block whichever of the six faces this is. #3AVEKC

face-cell
floor
-surface-point
*flat-normal0.5
face-seed
lattice-hash
+face-cell
*flat-normal0.37
face-hue
lattice-hash
+face-cell
vec35.211.379.13
patch
lattice-fractal-noise
*surface-point0.075
grain-point
*surface-point1.05
grain
lattice-noisegrain-point

Two more taps of the same grain, a step along each of the face's own in-plane axes: their differences are the field's gradient there, which is exactly the tilt a bump of it would give.

grain-u
lattice-noise
+grain-point
*tangent-u0.55
grain-v
lattice-noise
+grain-point
*tangent-v0.55
weathering
clamp
*surface-detail
+
*
-patch0.5
1.05
+
*
-face-seed0.5
0.30
*
-grain0.5
*0.30bevel-fade
-0.420.42

Brighter patches are also warmer and duller ones cooler, and a face's own constant drifts its hue as well as its value: a plain of grass wants to be many greens, not one green at many brightnesses.

hue-drift
clamp
*surface-detail
+
*
-face-hue0.5
0.36
*
-patch0.5
0.44
-0.350.35
weathered-tint
+
vec31.01.01.0
+
*
vec31.121.00.84
weathering
*
vec30.130.02-0.11
hue-drift

The grain is a cell-wide feature, so it survives to the distance a cell does rather than the distance a texel does.

bump-strength
*0.85
*bevel-fadesurface-detail
shaped
normalize
+
+
*roundedrelief-z
*tangent-u
+relief-x
*
-graingrain-u
bump-strength
*tangent-v
+relief-y
*
-graingrain-v
bump-strength
shading-normal
assume-quantityshaped:quantity:world-direction:unit:one

--- the micro-surface -------------------------------------------- The atlas normal is this material's own slope at texel scale, and the best evidence available about what happens below a texel: a steep texel is a rough material. When the relief fades out with distance its variance has to go somewhere, and the honest place is the roughness -- Toksvig's argument -- so a highlight keeps its size across the draw distance instead of sharpening into a sparkle on every distant face at once. #2T8CCH

relief-slope
sqrt
+
*
swizzletangent-normal:x
swizzletangent-normal:x
*
swizzletangent-normal:y
swizzletangent-normal:y
roughness
clamp
*surface-roughness
+
mix0.400.92
clamp
*relief-slope1.7
0.01.0
+
*0.28
-1.0relief-fade
*0.10
-grain0.5
0.160.99
sun-direction
swizzlesun-vector:xyz
n-dot-l
max0.0
dotshading-normalsun-direction
shadow-coordinateshadow-uv-input
shadow-u
swizzleshadow-coordinate:x
shadow-v
swizzleshadow-coordinate:y
shadow-in-bounds
*
step
quantity0.0:quantity:shadow-u:unit:one
shadow-u
stepshadow-u
quantity1.0:quantity:shadow-u:unit:one
step
quantity0.0:quantity:shadow-v:unit:one
shadow-v
stepshadow-v
quantity1.0:quantity:shadow-v:unit:one
shadow-texel-size
swizzleshadow-control-vector:xy
shadow-base-bias
swizzleshadow-control-vector:z
shadow-slope-bias
swizzleshadow-control-vector:w

A PCF tap displaced across a receiver plane must compare against that plane's depth at the tap, not the center fragment depth. With the orthographic shadow rows, the analytical UV depth gradient is the face normal expressed in the light basis and scaled by the world-span/depth-span ratio. Its denominator is bounded only near grazing incidence, where direct sun is negligible. This prevents wide kernels from reading the receiver's own slope as an occluder.

shadow-right
assume-quantity
normalize
swizzleshadow-row-x:xyz
:quantity:world-direction:unit:one
shadow-up
assume-quantity
normalize
swizzleshadow-row-y:xyz
:quantity:world-direction:unit:one
shadow-forward
assume-quantity
normalize
swizzleshadow-row-z:xyz
:quantity:world-direction:unit:one
shadow-normal-forward
min-0.05
dotnormalshadow-forward
shadow-depth-span
swizzleshadow-filter-vector:x
shadow-world-units-per-texel
swizzleshadow-filter-vector:y
shadow-world-span
interpret
/shadow-world-units-per-texel
swizzleshadow-texel-size:x
:quantity:world-distance:unit:cell
shadow-span-ratio
/shadow-world-spanshadow-depth-span
shadow-depth-gradient
interpret
vec2
-
assume-quantity
representation
*
/
dotnormalshadow-right
shadow-normal-forward
shadow-span-ratio
:quantity:shadow-depth-gradient:unit:one
-
assume-quantity
representation
*
/
dotnormalshadow-up
shadow-normal-forward
shadow-span-ratio
:quantity:shadow-depth-gradient:unit:one
:quantity:shadow-depth-gradient:unit:one
shadow-center-depth
swizzle
sampleshadow-mapshadow-samplershadow-coordinate
:x
receiver-depthshadow-depth-input
shadow-blocker-separation
interpret
max
quantity0.0:quantity:shadow-depth:unit:one:character:difference
-
-receiver-depthshadow-bias
shadow-center-depth
:quantity:shadow-depth:unit:one:character:absolute
shadow-minimum-radius
swizzleshadow-filter-vector:z
shadow-maximum-radius
swizzleshadow-filter-vector:w
sun-angular-width
swizzlesun-color-vector:w
shadow-penumbra-world-radius
interpret
*
*shadow-blocker-separationshadow-depth-span
sun-angular-width
:quantity:world-distance:unit:cell
shadow-filter-radius
clamp
+shadow-minimum-radius
interpret
/shadow-penumbra-world-radiusshadow-world-units-per-texel
:quantity:shadow-filter-radius:unit:one
shadow-minimum-radiusshadow-maximum-radius
shadow-sample
shadow-visibilityshadow-mapshadow-comparison-samplershadow-coordinatereceiver-depthshadow-depth-gradientshadow-texel-sizeshadow-biasshadow-filter-radius
sampled-shadow
mix1.0shadow-sampleshadow-in-bounds

A surface turned away from the light has no shadow decision to make: its own geometry already excludes the sun, the receiver plane's depth runs almost parallel to the light so every filter tap lands a large and increasingly ill-conditioned distance away, and the map's answer degenerates into noise. Near noon that is every vertical face in the world at once, which is why the shimmer was worst there. Fading the decision out toward "lit" exactly where it stops meaning anything costs nothing visually -- the same cosine multiplies the direct term to zero -- and gives the specular lobe and the diagnostic a stable value instead of the noise.

The lower edge is where the receiver-plane denominator above starts being clamped: SHADOW-FORWARD is the negated sun, so that dot product is exactly -N-DOT-L, and the clamp engages precisely below 0.05. The two mechanisms therefore hand over rather than overlap, and the clamp never has to carry a visible decision.

shadow-relevance
smoothstep0.050.22n-dot-l
direct-shadow
mix1.0sampled-shadowshadow-relevance

The mesh carries normalized raw light readings; every response curve and balance below is an art parameter editable live without remeshing the world.

sky-input
swizzlelight-input:x
block-input
swizzlelight-input:y
emission-input
swizzlelight-input:z
sky-level
*sky-inputsky-input
block-level
*block-inputblock-input
day-factor
swizzlesun-vector:w

Lateral skylight gives ambient visibility but not a hard sun beam; the shadow map gates only the direct solar term.

sun-visibility
smoothstep
quantity0.90:quantity:sky-light-level:unit:one
quantity1.0:quantity:sky-light-level:unit:one
sky-input
ambient
swizzleambient-vector:xyz
sun-color
swizzlesun-color-vector:xyz

--- the deck's shadow -------------------------------------------- The sky draws a cumulus deck on a plane at a fixed world height, and the ground under it should know. Following the sun up from this point to that plane and asking the very same field the very same coverage question is the whole of it: the deck's own shadow sweeping across the world, which is what keeps a plain at noon from being one flat sheet of light. The threshold is wider than the sky's, because a shadow thrown from that far off is blurred by the sun's own angular width long before it lands. #RSGLTL

elapsed
sun-climb
max0.15
representation
swizzlesun-direction:y
deck-reach
/
max8.0
-cloud-altitude
swizzlesurface-point:y
sun-climb
deck-meeting
+
+surface-point
*
representationsun-direction
deck-reach
vec3
*elapsed2.2
0.0
*elapsed1.2
deck-cover
cloud-fractal-noise
*deck-meeting0.0044
deck-coverage
cloud-shadow
assume-quantity
-1.0
*cloud-shadow-depth
smoothstepdeck-coverage
+deck-coverage0.20
deck-cover
:quantity:cloud-shadow:unit:one

Occlusion bites harder than the raw mesh reading: the corner where three blocks meet is what tells the eye these are solid volumes.

occlusion
interpret
*
exptao1.8
assume-quantityseam-occlusion:quantity:ambient-occlusion:unit:one
:quantity:ambient-occlusion:unit:one

--- what is not the sun ------------------------------------------ Everything above a face that is not the sun is still the sky, and the sky is not one colour: a face turned up sees the zenith, a face turned sideways sees the horizon, and a face turned down sees what the ground bounced. One interpolation over the normal's own vertical component is the whole of it, and it is the difference between blocks standing in a world and blocks floating in a grey room. The profile's ambient colour stays in the mixture: it is the art direction's say over light the geometry cannot explain.

facing-up
*0.5
+1.0
representation
swizzleshading-normal:y
dome-color
mix
representation
swizzlehorizon-vector:xyz
representation
swizzlezenith-vector:xyz
*facing-upfacing-up
bounce-color
environment
assume-quantity
mixbounce-color
mixdome-color0.62
facing-up
:quantity:linear-rgb:unit:one

A small floor keeps unlit geometry barely readable rather than a void; caves stay dark for the right reason. The ambient term is deliberately weaker, and the sun correspondingly stronger, than a display-referred renderer could afford: the filmic curve on presentation is what brings the sunlit half back down.

sky-light
interpret
*environment
+0.030
*0.86sky-level
occlusion
:quantity:linear-rgb:unit:one
sun-light
interpret
*sun-color
*direct-light-gainn-dot-lsun-visibilityday-factordirect-shadowcloud-shadow
mix0.551.0ao
:quantity:linear-rgb:unit:one
torch-color
quantity
vec31.00.820.58
:quantity:linear-rgb:unit:one
local-light
interpret
*torch-colorblock-level
:quantity:linear-rgb:unit:one
albedo
assume-quantity
*
representation
swizzle
sampleblock-atlasblock-sampleruv
:rgb
weathered-tint
:quantity:linear-rgb:unit:one
reflected
interpret
*albedo
+sky-lightsun-lightlocal-light
:quantity:linear-rgb:unit:one

--- the microfacet lobe ------------------------------------------ One GGX lobe off the shaped normal, with Smith's height-correlated visibility and a dielectric's Fresnel: blocks are not metal, so four per cent at normal incidence and the whole of it at a grazing one. The roughness above decides everything about its shape, so a polished face and a tufted one differ here without differing anywhere else.

view-direction
assume-quantity
normalize
-eyesurface-point
:quantity:world-direction:unit:one
half-vector
assume-quantity
normalize
+
normalize
-eyesurface-point
representationsun-direction
:quantity:world-direction:unit:one
n-dot-h
max0.0
dotshading-normalhalf-vector
n-dot-v
max0.0
dotshading-normalview-direction
v-dot-h
max0.0
dotview-directionhalf-vector
cosine-light
cosine-view
cosine-half
alpha
*roughnessroughness
alpha-squared
*alphaalpha
distribution-denominator
+
*
*cosine-halfcosine-half
-alpha-squared1.0
1.0
distribution
/alpha-squared
max0.0001
*3.14159265
*distribution-denominatordistribution-denominator

Smith's height-correlated visibility already carries the 1/(4 cos cos) the microfacet specular would otherwise divide by.

visibility-light
*cosine-view
sqrt
+
*
*cosine-lightcosine-light
-1.0alpha-squared
alpha-squared
visibility-view
*cosine-light
sqrt
+
*
*cosine-viewcosine-view
-1.0alpha-squared
alpha-squared
visibility
/0.5
max0.0001
+visibility-lightvisibility-view
fresnel
+0.04
*0.96
expt
-1.0
5.0

The cosine belongs in the specular lobe as much as in the diffuse one: without it a highlight can appear on a face the sun cannot reach, carrying whatever the shadow map happened to say there.

sun-specular
*
*
*distribution
*visibilityfresnel

The same sky again, in the direction the surface actually reflects: the sheen that tells a smooth face from a rough one out of the sun, and the one term that makes a block look like it is standing under this weather rather than under a light bulb.

reflection-direction
-
*
representationshading-normal
*2.0cosine-view
representationview-direction
reflection-up
clamp
*0.5
+1.0
swizzlereflection-direction:y
0.01.0
reflected-sky
mix
representation
swizzlehorizon-vector:xyz
representation
swizzlezenith-vector:xyz
*reflection-upreflection-up
sheen
*
-1.0roughness
+0.035
*0.32
expt
-1.0cosine-view
5.0
ambient-specular
*reflected-sky
*sheen
specular
assume-quantity
+sun-specularambient-specular
:quantity:linear-rgb:unit:one
radiance
+reflectedspecular
interpret
*albedoemission-input
:quantity:linear-rgb:unit:one

Distant terrain fades into exactly the colour the sky's own ground half arrives at, warmed where the ray runs toward a low sun: the aerial perspective that puts a mile of air between here and the horizon.

look-direction
normalize
-surface-pointeye
sun-elevation
representation
swizzlesun-direction:y
low-sun
*
-1.0
smoothstep0.020.45sun-elevation
fog-color
assume-quantity:quantity:linear-rgb:unit:one
fog-amountfog-input
fogged
mixradiancefog-colorfog-amount
normal-rgba
assume-quantity
vec4
representation
quantity1.0:quantity:opacity:unit:one
:quantity:linear-rgba:unit:one
shadow-diagnostic
swizzlefog-color-vector:w
shadow-rgba
assume-quantity
vec4
representation
vec3direct-shadowdirect-shadowdirect-shadow
representation
quantity1.0:quantity:opacity:unit:one
:quantity:linear-rgba:unit:one
rgba
mixnormal-rgbashadow-rgbashadow-diagnostic
set-outputcolor-outputrgba

Phase 3: one crystal proves emission and edit propagation #SKCRYS

*crystal-block* carries light emission 12, surface emission 1.2, and its own atlas tile. Its surface-emission value makes the crystal face luminous; its light-emission value seeds the propagated field around it. Those are related but distinct facts, which is why the mesh carries material emission separately from the blocklight level.

Add it to *placeable-block-kinds* and make the key range and title derive from the palette length instead of a fixed seven-material assumption. A block removal or placement feeds the ordinary content-change path: direct sky, blocklight, light publication, mesh invalidation, and GPU replacement all complete without a special crystal edit command.

Gate

At pinned night, a crystal remains visibly emissive and warms neighboring surfaces with a continuous cross-chunk falloff. Placing and removing it converges to the reference field, rejects any stale mesh work already in flight, and updates the visible neighborhood within a measured latency budget.

Phase 4: HDR scene colour, tone mapping, and restrained bloom #SKHDR

Bloom is not “three easy passes” on the current target. First add an explicit linear HDR scene format (normally RGBA16F) to the GPU API/backend and render sky plus world into it. A live fullscreen post pipeline tone maps into an LDR sRGB presentation target which can be copied to the surface as today.

Then add half-resolution bright extraction, separable blur, and composite. The extraction can use HDR luminance and a threshold/knee expressed with the new shader math. If art direction later requires “emissive materials only,” add a named emission attachment and multiple render targets; do not overload scene alpha. All intermediate textures have explicit session ownership, resize handling, usage flags, and completion-safe destruction.

The first bloom target is deliberately the crystal at night. Exposure, threshold, radius, and intensity are live profile values, while the pinned smoke profile keeps deterministic output.

What the presentation stack became #IC14P3

The plan of #SKHDR is built, and the shape it took is worth recording because two of its decisions were made by the implementation rather than by the plan.

The scene attachment is :rgba16-float and every scene-stage pipeline targets it: block surfaces, the sky triangle, world text, the terminal wall's glyph and cell runs, the crosshair. Only the presentation image is an sRGB eight-bit surface, and the hardware encode on that write is the only gamma step in the frame. Fragments therefore write radiance, and a sun disc, a specular glint, or a crystal is allowed to be many times display white.

Presentation runs a quarter-resolution lens chain between the scene pass and the tone map: a soft-knee bright pass with four scene taps per chain texel, a separable nine-tap gaussian expressed as five linear samples, and a radial sweep toward the solar disc. The chain ping-pongs between two attachments, so the blurred bloom ends in one and the shafts in the other, and presentation adds both into linear scene radiance before the fitted ACES curve, a small vignette, and the sRGB write.

Two departures from the plan:

Grading is art direction rather than architecture, so exposure, bloom gain, threshold, shaft gain and decay, and vignette are ordinary special variables that a SLY eval retunes on the running game without rebuilding a pipeline.

define-shader-method shader-specification-for shaders.lisp:2332

The bloom and light-shaft chain runs on a quarter-resolution pair of floating-point attachments. Bright fragments are extracted once, blurred separably, and then swept radially away from the solar disc; presentation adds both back into linear scene radiance before the filmic curve. Every stage sees the same bind group shape -- source texture, linear sampler, presentation uniforms -- so one layout serves the whole chain, and the stage that reads it is chosen by CLOS role rather than by a mode lane. #IC14P3 records the shape this stack took and where it left its plan.

define-shader-methodshader-specification-forbloom-bright-fragment-specification
role
eql:bloom-bright
stage
eql:fragment
:stage:fragment:inputs
uv-input:vec2:location0
:outputs
color-output:vec4:location0
:resources
source-color:texture-2d:set0:binding0:sample-transfer:identity
source-sampler:sampler:set0:binding1
post-state:uniform-block:set0:binding2:members#.*post-uniform-members*
let*
texel
swizzlepost-control:xy
exposure
swizzlepost-control:w
threshold
swizzlelens-control:w
dx
*texel
vec21.00.0
dy
*texel
vec20.01.0

Four scene taps per chain texel. The chain is a quarter of the frame's width, so a single tap would alias exactly the highlights this pass exists to smear.

box
*
+
swizzle
samplesource-colorsource-sampler
+uv-input
+dxdy
:xyz
swizzle
samplesource-colorsource-sampler
+uv-input
-dxdy
:xyz
swizzle
samplesource-colorsource-sampler
-uv-input
-dxdy
:xyz
swizzle
samplesource-colorsource-sampler
-uv-input
+dxdy
:xyz
0.25

The chain works in exposed units, so its contribution stays in step with the scene when exposure moves.

radiance
luminance
dotradiance
vec30.21260.71520.0722
knee
smoothstepthreshold
+threshold0.75
luminance
set-outputcolor-output
vec4
*radianceknee
1.0

The screen centre is the focus plane #RLXR7B

Luvcraft chooses focus from the image itself: the presentation pass samples scene depth under the centre of the frame, then smoothly mixes only farther fragments toward a nine-tap tent blur. Near ground and the framed subject stay crisp; radial colour dispersion and a gentle vignette give the result lens character, while the HUD is drawn afterward and remains untouched.

That convention travels without taking the renderer with it. LUFT uses the same centre-relative rule but carries view distance in scene alpha, omits the bloom and shaft attachments, and owns its smaller lens pass independently. The shared shader language lets image mathematics cross between the two worlds while their resources and lifetimes remain honest.

A sky worth tone mapping #9SSXDJ

An HDR path only pays for itself if something in the frame is genuinely bright, and a two-colour gradient with a soft disc is not. The sky fragment stage became image mathematics over the view ray and the frame environment: a shaped vertical gradient between the profile's horizon and zenith colours, a warm low band and two Mie lobes around the sun, a fractal-noise cloud deck projected onto a plane at cloud height and drifting with an elapsed time the frame environment now carries, stars at night, and a solar disc drawn at a few times the sun's true angular radius and pushed far past display white so the bright pass has something real to feed on.

Only the last few degrees above level fade into the fog colour distant terrain fades to. The wide horizon blend the earlier gradient used washed the whole lower sky into haze, which reads as a rendering limitation rather than as weather.

The noise is lattice value noise, and its hash taught a lesson worth keeping: hashing the collapsed index x + 57y + 113z through a sine gives every plane of constant index the same value, and at star-field frequencies those planes are plainly visible as streaks across the sky. Folding a site's own components against each other has no preferred direction, and costs less than eight sines besides.

define-shader-method shader-specification-for shaders.lisp:2031

The sky is image mathematics over a view ray and the frame environment. What it argues is that everything up there is one atmosphere seen at different depths:

  • the vertical gradient runs the whole way from horizon to zenith

rather than resolving in the first few degrees, because that is what the optical depth along a ray actually does;

  • haze thickens toward the horizon, and the sunrise band it carries

belongs to the sun's own quarter of the compass, not all the way round;

  • one Henyey-Greenstein phase function, evaluated at two asymmetries,

is the whole glow around the sun;

  • two cloud decks, each a plane at a fixed height, so the ray meets

them farther out the closer it runs to level: thin cirrus far above, and the cumulus sheet whose own shadow, sampled once along the deck toward the sun, gives it a lit face and a dark underside;

  • at night, points for stars, a band for the galaxy, and the moon

opposite the sun;

  • a compact solar disc pushed well past display white so the lens

chain has something real to bloom.

Everything below the horizon arrives at the exact fog colour distant terrain fades to, so silhouettes meet the sky without a seam, and only then darkens -- gently, because a stronger falloff shows up as a step where the last resident chunk ends. #9SSXDJ records why an HDR path wants a sky with something genuinely bright in it, and #8X33G2 what each of these terms replaced.

define-shader-methodshader-specification-forblock-world-sky-fragment-specification
role
eql:sky
stage
eql:fragment
:stage:fragment:inputs
ray-input:vec3:location0:quantity:world-direction:unit:one
:outputs
color-output:vec4:location0
:resources
frame-state:uniform-block:set0:binding2:members#.*frame-uniform-members*
let*
direction
elevation
swizzledirection:y
above
maxelevation0.0
eye
representation
swizzlecamera-vector:xyz
sun-direction
representation
swizzlesun-vector:xyz
sun-color
representation
swizzlesun-color-vector:xyz
sun-width
representation
swizzlesun-color-vector:w
zenith
representation
swizzlezenith-vector:xyz
horizon
representation
swizzlehorizon-vector:xyz
fog-color
representation
swizzlefog-color-vector:xyz
elapsed

One number decides how much of the sky is sunrise: a sun near the horizon warms the haze, the scatter, and the cloud faces together.

sun-elevation
swizzlesun-direction:y
low-sun
*day-factor
-1.0
smoothstep0.020.45sun-elevation
alignment
dotdirectionsun-direction
toward-sun
max0.0alignment

The sun's half of the sky, measured on the ground plane: a sunrise band belongs in the east, and the west should stay blue.

level-ray
vec3
swizzledirection:x
0.0
swizzledirection:z
level-sun
vec3
swizzlesun-direction:x
0.0
swizzlesun-direction:z
azimuth
max0.0
/
dotlevel-raylevel-sun
max0.001
*
sqrt
dotlevel-raylevel-ray
sqrt
dotlevel-sunlevel-sun

--- the atmosphere --------------------------------------------- The vertical gradient is the sky's own colour at depth; a small exponent spreads it across the whole hemisphere instead of resolving it in the first few degrees, which is what an optical depth along the ray actually does.

gradient
expt
clampelevation0.01.0
0.42
base
mixhorizonzenithgradient
haze
exp
*-9.0above

A sunrise is sunlight that has come the long way through the atmosphere, so the band it paints is the sun's own colour at that hour, laid exactly where the ray runs both low and toward it. Away from the sun's quarter of the compass the sky stays its own colour, which is what keeps the west blue while the east burns.

warm-color
*sun-color0.82
warm-band
*low-sun
*haze
+0.12
*0.88
*azimuthazimuth
hazed
mixbasewarm-color
clamp
*0.92warm-band
0.01.0
broad
henyey-greensteinalignment0.62
tight
henyey-greensteinalignment0.90
halo-tint
mix
vec31.00.970.90
vec31.00.520.24
low-sun
halo-depth
mix0.701.60haze
halo
*
+
*broad
+0.030
*0.055low-sun
*tight
+0.014
*0.055low-sun
scattered
+hazed
*halo-tinthalo

--- night ------------------------------------------------------

night

The galaxy is a great circle of the sky, so one fixed axis and the ray's distance from its plane is the whole band.

galaxy-axis
vec30.420.55-0.72
galaxy-distance
dotdirectiongalaxy-axis
galaxy-band
exp
*-20.0
*galaxy-distancegalaxy-distance
galaxy-structure
+
*
lattice-noise
*direction15.0
0.58
*
lattice-noise
*direction44.0
0.42
galaxy
*galaxy-band
*
+0.10
*1.25galaxy-structure

A dust lane is the band's own darkness, not an absence of stars, so it multiplies rather than subtracts.

-1.0
*0.55
smoothstep0.420.66
lattice-noise
*direction7.0
star-visibility
*night
smoothstep-0.020.14elevation
stars
*
star-lightdirectionelapsed
*star-brightness
*star-visibility
+1.0
*1.6galaxy-band
starred
+
*
vec30.600.660.94
*galaxy
*star-visibility0.17
*
vec30.920.941.0
stars

The moon rides opposite the sun, which puts it up for exactly the hours the sun is not, and always full: the simple sky this world wants, and the one that lights its nights.

moon-direction
*sun-direction-1.0
moon-alignment
dotdirectionmoon-direction
moon-radius
*sun-width1.7
moon-limb
*0.5
*moon-radiusmoon-radius
moon-disc
smoothstep
-1.0moon-limb
-1.0
*0.80moon-limb
moon-alignment

The component of the ray across the moon's own direction, in units of its radius: the disc's face, which the maria are painted on.

moon-face
/
-direction
*moon-directionmoon-alignment
max0.0001moon-radius
moon-maria
lattice-noise
+
*moon-face1.8
vec313.05.09.0
moon-shape
*
+0.70
*0.50moon-maria
-1.0
*0.40
clamp
dotmoon-facemoon-face
0.01.0
moon-glow
*
expt
max0.0moon-alignment
220.0
0.30
lunar
*
vec30.940.951.0
*night
+
*moon-disc
*moon-radiancemoon-shape
moon-glow
nightly
+scattered
+starredlunar

--- the cloud decks --------------------------------------------

cirrus-point
+
*direction
/2400.0
maxelevation0.02
vec3
*elapsed6.0
0.0
*elapsed2.0
cirrus-field
lattice-fractal-noise
*cirrus-point
vec30.001050.001050.00225
cirrus
*
smoothstep0.560.88cirrus-field
*
smoothstep0.020.26elevation
*
-1.0
0.45
cirrus-color
*
mix
vec31.101.121.18
vec31.380.840.56
*low-sunlow-sun
with-cirrus
mixnightlycirrus-colorcirrus

The deck is a plane at a fixed height in the world, not a fixed height above the camera: the ground has to be able to find the same plane along the sun and read the same field there, or the shadows sweeping over it would belong to some other sky. #RSGLTL

deck-rise
deck-point
+
+eye
*direction
/deck-rise
maxelevation0.014
vec3
*elapsed2.2
0.0
*elapsed1.2
deck-scale0.0044
cloud-field
cloud-fractal-noise
*deck-pointdeck-scale

Coverage is the profile's cloudiness against the knob; the edge softens toward the horizon, where a deck's detail is far smaller than a pixel and a hard edge could only shimmer.

coverage
softness
mix0.260.055
smoothstep0.0120.34elevation
deck-mask
smoothstep0.0060.055elevation
cloud-density
*deck-mask
smoothstepcoverage
+coveragesoftness
cloud-field
core
clampcloud-density0.01.0

What lies between this piece of deck and the sun, sampled along the deck itself: the shape's own shadow, and so its lit face.

shadow-field
cloud-fractal-noise
*
+deck-point
*level-sun300.0
deck-scale
shadow-density
smoothstepcoverage
+coveragesoftness
shadow-field
cloud-light
-1.0
*0.70shadow-density
cloud-lit
mix
vec31.221.201.16
vec31.400.740.36
*low-sunlow-sun
cloud-dark
mix
vec30.500.570.74
vec30.440.380.52
*low-sunlow-sun
cloud-body
mixcloud-darkcloud-lit
*cloud-light
-1.0
*0.45core

Silver lining: thin edges facing the sun glow, dense cores do not.

silver
*cloud-lit
*
expttoward-sun14.0
*
-1.0core
+0.30
*0.85low-sun

After sunset a deck is lit by the sky alone, so it takes the sky's own colour; a neutral grey at one twentieth reads warm against a night zenith and the eye calls it dust.

night-tint
mix
vec30.440.520.80
vec31.01.01.0
cloud-color
*
*
+cloud-bodysilver
night-tint

A deck recedes into the same haze the sky's own horizon does.

cloud-reach
smoothstep0.0100.11elevation
clouded
mixwith-cirrus
mixhazedcloud-colorcloud-reach
*cloud-density0.94

--- the ground half -------------------------------------------- Below the horizon this shader stands in for terrain too far off to be resident, so it must arrive at exactly the colour distant terrain fades to. It may still say where the land is: the ray's own meeting with the ground plane gives a point to sample, and a wide, weak relief over it keeps the lower half from being one flat wall of fog. The relief fades out at the horizon, where the plane runs away faster than a pixel can resolve it.

aerial
aerial-perspective-colorfog-colordirectionsun-directionlow-sunday-factor
descent
max0.004
-elevation
ground-point
+eye
*direction
/
max1.0
swizzleeye:y
descent
land
lattice-fractal-noise
*ground-point0.0021
land-relief
*
-land0.5
smoothstep0.0-0.22elevation
depth-below
smoothstep0.0-0.35elevation
ground
*aerial
+
-1.0
*0.26depth-below
*0.44land-relief
grounded
mixcloudedground
smoothstep0.060-0.006elevation

--- the sun ---------------------------------------------------- The disc is drawn at a few times the sun's true angular radius, the way every game sun is, and deliberately far above display white: the floating point attachment keeps it, the bright pass feeds on it, and the filmic curve rolls it into a hot core instead of a flat clipped patch. For a small angle the cosine threshold of an angular radius is one less half its square.

disc-radius
disc-limb
*0.5
*disc-radiusdisc-radius
disc
smoothstep
-1.0disc-limb
-1.0
*0.56disc-limb
alignment

A star's disc is brightest at its centre; without the limb darkening a sun drawn this large reads as a sticker.

disc-radial
clamp
/
-1.0alignment
max0.000001disc-limb
0.01.0
limb-darkening
-1.0
*0.45
*disc-radialdisc-radial
corona
+
*
expttoward-sun900.0
0.8
*
expttoward-sun130.0
0.22
occlusion
-1.0
*core0.94
solar
*sun-color
*day-factor
+
*disc
*sun-disc-radiance
*occlusionlimb-darkening
*corona
*2.0occlusion
rgb
+groundedsolar
set-outputcolor-output

The sky became an atmosphere #8X33G2

#9SSXDJ gave the sky something worth tone mapping. What it drew was still a gradient with weather painted over it, and five of its terms were wrong in the same way: each was a fitted curve standing where a physical argument was available.

The cirrus deck above the cumulus is new, and so is the deck lighting: one extra tap of the cloud field along the deck toward the sun is the shape's own shadow, and therefore its lit face and its dark underside.

define-shader-function aerial-perspective-color shaders.lisp:1957
define-shader-functionaerial-perspective-color
fog-colordirectionsun-directionlow-sunday-factor

The colour something arbitrarily far away takes, seen along DIRECTION.

Distant terrain and the sky's own ground half must agree exactly, or the edge of the resident world draws itself as a line. They agree by asking this: the profile's fog colour, warmed where the ray runs toward a low sun and brightened by the same haze the sky's halo is made of, so a ridge in the east at dawn glows and one in the west stays cool. #8X33G2

let*
alignment
dotdirectionsun-direction
toward
max0.0alignment
glow
henyey-greensteinalignment0.66
warm
mix
vec31.080.740.46
vec31.250.520.27
low-sun
+
*fog-color
-1.0
*0.18
*low-suntoward
*warm
*day-factor
+
*glow0.055
*low-sun
*
*towardtoward
0.22
define-shader-method shader-specification-for shaders.lisp:2031

The sky is image mathematics over a view ray and the frame environment. What it argues is that everything up there is one atmosphere seen at different depths:

  • the vertical gradient runs the whole way from horizon to zenith

rather than resolving in the first few degrees, because that is what the optical depth along a ray actually does;

  • haze thickens toward the horizon, and the sunrise band it carries

belongs to the sun's own quarter of the compass, not all the way round;

  • one Henyey-Greenstein phase function, evaluated at two asymmetries,

is the whole glow around the sun;

  • two cloud decks, each a plane at a fixed height, so the ray meets

them farther out the closer it runs to level: thin cirrus far above, and the cumulus sheet whose own shadow, sampled once along the deck toward the sun, gives it a lit face and a dark underside;

  • at night, points for stars, a band for the galaxy, and the moon

opposite the sun;

  • a compact solar disc pushed well past display white so the lens

chain has something real to bloom.

Everything below the horizon arrives at the exact fog colour distant terrain fades to, so silhouettes meet the sky without a seam, and only then darkens -- gently, because a stronger falloff shows up as a step where the last resident chunk ends. #9SSXDJ records why an HDR path wants a sky with something genuinely bright in it, and #8X33G2 what each of these terms replaced.

define-shader-methodshader-specification-forblock-world-sky-fragment-specification
role
eql:sky
stage
eql:fragment
:stage:fragment:inputs
ray-input:vec3:location0:quantity:world-direction:unit:one
:outputs
color-output:vec4:location0
:resources
frame-state:uniform-block:set0:binding2:members#.*frame-uniform-members*
let*
direction
elevation
swizzledirection:y
above
maxelevation0.0
eye
representation
swizzlecamera-vector:xyz
sun-direction
representation
swizzlesun-vector:xyz
sun-color
representation
swizzlesun-color-vector:xyz
sun-width
representation
swizzlesun-color-vector:w
zenith
representation
swizzlezenith-vector:xyz
horizon
representation
swizzlehorizon-vector:xyz
fog-color
representation
swizzlefog-color-vector:xyz
elapsed

One number decides how much of the sky is sunrise: a sun near the horizon warms the haze, the scatter, and the cloud faces together.

sun-elevation
swizzlesun-direction:y
low-sun
*day-factor
-1.0
smoothstep0.020.45sun-elevation
alignment
dotdirectionsun-direction
toward-sun
max0.0alignment

The sun's half of the sky, measured on the ground plane: a sunrise band belongs in the east, and the west should stay blue.

level-ray
vec3
swizzledirection:x
0.0
swizzledirection:z
level-sun
vec3
swizzlesun-direction:x
0.0
swizzlesun-direction:z
azimuth
max0.0
/
dotlevel-raylevel-sun
max0.001
*
sqrt
dotlevel-raylevel-ray
sqrt
dotlevel-sunlevel-sun

--- the atmosphere --------------------------------------------- The vertical gradient is the sky's own colour at depth; a small exponent spreads it across the whole hemisphere instead of resolving it in the first few degrees, which is what an optical depth along the ray actually does.

gradient
expt
clampelevation0.01.0
0.42
base
mixhorizonzenithgradient
haze
exp
*-9.0above

A sunrise is sunlight that has come the long way through the atmosphere, so the band it paints is the sun's own colour at that hour, laid exactly where the ray runs both low and toward it. Away from the sun's quarter of the compass the sky stays its own colour, which is what keeps the west blue while the east burns.

warm-color
*sun-color0.82
warm-band
*low-sun
*haze
+0.12
*0.88
*azimuthazimuth
hazed
mixbasewarm-color
clamp
*0.92warm-band
0.01.0
broad
henyey-greensteinalignment0.62
tight
henyey-greensteinalignment0.90
halo-tint
mix
vec31.00.970.90
vec31.00.520.24
low-sun
halo-depth
mix0.701.60haze
halo
*
+
*broad
+0.030
*0.055low-sun
*tight
+0.014
*0.055low-sun
scattered
+hazed
*halo-tinthalo

--- night ------------------------------------------------------

night

The galaxy is a great circle of the sky, so one fixed axis and the ray's distance from its plane is the whole band.

galaxy-axis
vec30.420.55-0.72
galaxy-distance
dotdirectiongalaxy-axis
galaxy-band
exp
*-20.0
*galaxy-distancegalaxy-distance
galaxy-structure
+
*
lattice-noise
*direction15.0
0.58
*
lattice-noise
*direction44.0
0.42
galaxy
*galaxy-band
*
+0.10
*1.25galaxy-structure

A dust lane is the band's own darkness, not an absence of stars, so it multiplies rather than subtracts.

-1.0
*0.55
smoothstep0.420.66
lattice-noise
*direction7.0
star-visibility
*night
smoothstep-0.020.14elevation
stars
*
star-lightdirectionelapsed
*star-brightness
*star-visibility
+1.0
*1.6galaxy-band
starred
+
*
vec30.600.660.94
*galaxy
*star-visibility0.17
*
vec30.920.941.0
stars

The moon rides opposite the sun, which puts it up for exactly the hours the sun is not, and always full: the simple sky this world wants, and the one that lights its nights.

moon-direction
*sun-direction-1.0
moon-alignment
dotdirectionmoon-direction
moon-radius
*sun-width1.7
moon-limb
*0.5
*moon-radiusmoon-radius
moon-disc
smoothstep
-1.0moon-limb
-1.0
*0.80moon-limb
moon-alignment

The component of the ray across the moon's own direction, in units of its radius: the disc's face, which the maria are painted on.

moon-face
/
-direction
*moon-directionmoon-alignment
max0.0001moon-radius
moon-maria
lattice-noise
+
*moon-face1.8
vec313.05.09.0
moon-shape
*
+0.70
*0.50moon-maria
-1.0
*0.40
clamp
dotmoon-facemoon-face
0.01.0
moon-glow
*
expt
max0.0moon-alignment
220.0
0.30
lunar
*
vec30.940.951.0
*night
+
*moon-disc
*moon-radiancemoon-shape
moon-glow
nightly
+scattered
+starredlunar

--- the cloud decks --------------------------------------------

cirrus-point
+
*direction
/2400.0
maxelevation0.02
vec3
*elapsed6.0
0.0
*elapsed2.0
cirrus-field
lattice-fractal-noise
*cirrus-point
vec30.001050.001050.00225
cirrus
*
smoothstep0.560.88cirrus-field
*
smoothstep0.020.26elevation
*
-1.0
0.45
cirrus-color
*
mix
vec31.101.121.18
vec31.380.840.56
*low-sunlow-sun
with-cirrus
mixnightlycirrus-colorcirrus

The deck is a plane at a fixed height in the world, not a fixed height above the camera: the ground has to be able to find the same plane along the sun and read the same field there, or the shadows sweeping over it would belong to some other sky. #RSGLTL

deck-rise
deck-point
+
+eye
*direction
/deck-rise
maxelevation0.014
vec3
*elapsed2.2
0.0
*elapsed1.2
deck-scale0.0044
cloud-field
cloud-fractal-noise
*deck-pointdeck-scale

Coverage is the profile's cloudiness against the knob; the edge softens toward the horizon, where a deck's detail is far smaller than a pixel and a hard edge could only shimmer.

coverage
softness
mix0.260.055
smoothstep0.0120.34elevation
deck-mask
smoothstep0.0060.055elevation
cloud-density
*deck-mask
smoothstepcoverage
+coveragesoftness
cloud-field
core
clampcloud-density0.01.0

What lies between this piece of deck and the sun, sampled along the deck itself: the shape's own shadow, and so its lit face.

shadow-field
cloud-fractal-noise
*
+deck-point
*level-sun300.0
deck-scale
shadow-density
smoothstepcoverage
+coveragesoftness
shadow-field
cloud-light
-1.0
*0.70shadow-density
cloud-lit
mix
vec31.221.201.16
vec31.400.740.36
*low-sunlow-sun
cloud-dark
mix
vec30.500.570.74
vec30.440.380.52
*low-sunlow-sun
cloud-body
mixcloud-darkcloud-lit
*cloud-light
-1.0
*0.45core

Silver lining: thin edges facing the sun glow, dense cores do not.

silver
*cloud-lit
*
expttoward-sun14.0
*
-1.0core
+0.30
*0.85low-sun

After sunset a deck is lit by the sky alone, so it takes the sky's own colour; a neutral grey at one twentieth reads warm against a night zenith and the eye calls it dust.

night-tint
mix
vec30.440.520.80
vec31.01.01.0
cloud-color
*
*
+cloud-bodysilver
night-tint

A deck recedes into the same haze the sky's own horizon does.

cloud-reach
smoothstep0.0100.11elevation
clouded
mixwith-cirrus
mixhazedcloud-colorcloud-reach
*cloud-density0.94

--- the ground half -------------------------------------------- Below the horizon this shader stands in for terrain too far off to be resident, so it must arrive at exactly the colour distant terrain fades to. It may still say where the land is: the ray's own meeting with the ground plane gives a point to sample, and a wide, weak relief over it keeps the lower half from being one flat wall of fog. The relief fades out at the horizon, where the plane runs away faster than a pixel can resolve it.

aerial
aerial-perspective-colorfog-colordirectionsun-directionlow-sunday-factor
descent
max0.004
-elevation
ground-point
+eye
*direction
/
max1.0
swizzleeye:y
descent
land
lattice-fractal-noise
*ground-point0.0021
land-relief
*
-land0.5
smoothstep0.0-0.22elevation
depth-below
smoothstep0.0-0.35elevation
ground
*aerial
+
-1.0
*0.26depth-below
*0.44land-relief
grounded
mixcloudedground
smoothstep0.060-0.006elevation

--- the sun ---------------------------------------------------- The disc is drawn at a few times the sun's true angular radius, the way every game sun is, and deliberately far above display white: the floating point attachment keeps it, the bright pass feeds on it, and the filmic curve rolls it into a hot core instead of a flat clipped patch. For a small angle the cosine threshold of an angular radius is one less half its square.

disc-radius
disc-limb
*0.5
*disc-radiusdisc-radius
disc
smoothstep
-1.0disc-limb
-1.0
*0.56disc-limb
alignment

A star's disc is brightest at its centre; without the limb darkening a sun drawn this large reads as a sticker.

disc-radial
clamp
/
-1.0alignment
max0.000001disc-limb
0.01.0
limb-darkening
-1.0
*0.45
*disc-radialdisc-radial
corona
+
*
expttoward-sun900.0
0.8
*
expttoward-sun130.0
0.22
occlusion
-1.0
*core0.94
solar
*sun-color
*day-factor
+
*disc
*sun-disc-radiance
*occlusionlimb-darkening
*corona
*2.0occlusion
rgb
+groundedsolar
set-outputcolor-output

A cloud deck is a plane both halves of the renderer can find #RSGLTL

The cumulus deck used to be a plane a fixed distance above the camera, sampled in camera-relative coordinates: clouds at infinity, which is a perfectly good cheat until something on the ground wants to know about them.

It is a plane at a fixed height in the world now. The sky finds it along the view ray; the block material finds it by following the sun up from the fragment, and asks the very same field the very same coverage question there. The answer multiplies the direct sun only --- never the ambient, which is the sky the cloud is part of --- and its threshold is wider than the sky's, because a shadow thrown from that far off is blurred by the sun's own angular width long before it lands.

One fractal noise per fragment buys what no amount of material work could: a plain at noon stops being one flat sheet of light, because the weather is moving across it.

define-shader-method shader-specification-for shaders.lisp:1218
define-shader-methodshader-specification-forblock-world-fragment-specification
role
eql:block-surface
stage
eql:fragment
:stage:fragment:inputs
uv-shade-input:vec3:location0:components
:xy:quantity:texture-uv:unit:one
:z:quantity:ambient-occlusion:unit:one
normal-input:vec3:location1:quantity:world-direction:unit:one
fog-input:float:location2:quantity:fog-amount:unit:one
light-input:vec3:location3:components
:x:quantity:sky-light-level:unit:one
:y:quantity:block-light-level:unit:one
:z:quantity:material-emission:unit:one
shadow-uv-input:vec2:location4:quantity:shadow-uv:unit:one
shadow-depth-input:float:location5:quantity:shadow-depth:unit:one
world-position-input:vec3:location6:quantity:world-position:unit:cell
edge-shaping-input:vec4:location7
tile-offset-input:float:location8:quantity:atlas-tile-offset:unit:one:interpolation:flat
:outputs
color-output:vec4:location0
:resources
block-atlas:texture-2d:set0:binding0:sample-transfer:srgb-to-linear:sample-components
:rgb:quantity:linear-rgb:unit:one
block-sampler:sampler:set0:binding1
frame-state:uniform-block:set0:binding2:members#.*frame-uniform-members*
shadow-map:depth-texture-2d:set0:binding3:sample-components
:x:quantity:shadow-depth:unit:one
shadow-sampler:sampler:set0:binding4
shadow-comparison-sampler:sampler:set0:binding5
block-normal-atlas:texture-2d:set0:binding6:sample-transfer:identity:sample-components
:rgb:quantity:surface-normal-sample:unit:one
:a:quantity:surface-relief:unit:one
let*
uv-shadeuv-shade-input
uv
swizzleuv-shade:xy
ao
swizzleuv-shade:z
normalnormal-input

--- the shaped surface ------------------------------------------- A block face is flat geometry, but it should not read as a flat material. Two shapings ride on the same face: a bevel that rounds the last sixth of a cell toward its edge, and a per-texel relief read straight out of the atlas the material is already painted in. Both are pure normal perturbation, so no mesh changes.

flat-normal
representationnormal-input
surface-point
representationworld-position-input
eye
representation
swizzlecamera-vector:xyz
cell
fractsurface-point
axis-x
abs
swizzleflat-normal:x
axis-y
abs
swizzleflat-normal:y

A block face's own normal selects its two in-plane world axes, so one expression serves all six faces without a branch.

tangent-u
vec3
-1.0axis-x
0.0axis-x
tangent-v
vec30.0
-1.0axis-y
axis-y
plane-u
plane-v

The atlas coordinate and its screen derivative size both shapings. The normal itself is already the central difference of the same procedural relief field that paints the colour atlas.

tile-count11.0
tile-texels16.0

Texels per pixel: one cell is TILE-TEXELS of them.

footprint
max
*
*tile-counttile-texels
*tile-texels

Both shapings are sub-block detail, so both have to fade out as soon as they stop resolving on screen; otherwise distant terrain sparkles and shows a lit grid instead of a surface. One measured footprint drives both, at their own scales.

relief-fade
-1.0
smoothstep0.301.10footprint
bevel-fade
-1.0
smoothstep1.605.00footprint

The mesher's edge classification decides what each boundary does. A convex edge rounds the surface over; a concave one fillets it into the inner corner; a flush one -- the middle of an open plain, where the face simply continues into an identically oriented neighbour -- leaves the surface alone. Signs come straight from the vertex lane, so the same ramp expression serves all three.

edgeedge-shaping-input
edge-u-low
swizzleedge:x
edge-u-high
swizzleedge:y
edge-v-low
swizzleedge:z
edge-v-high
swizzleedge:w

A round-over is only legible if it is a few pixels wide. Held to a fixed fraction of a cell it dwindles to nothing a short way off, and every block in the middle distance flattens back into a card; grown with the footprint it keeps its shape until it is genuinely too small to resolve. The upper bound stops it from eating the face it is supposed to be an edge of.

bevel-width
clamp
*0.115footprint
0.1050.185
ramp-u-low
smoothstepbevel-width0.0plane-u
ramp-u-high
smoothstep
-1.0bevel-width
1.0plane-u
ramp-v-low
smoothstepbevel-width0.0plane-v
ramp-v-high
smoothstep
-1.0bevel-width
1.0plane-v
bevel-u
-
*ramp-u-highedge-u-high
*ramp-u-lowedge-u-low
bevel-v
-
*ramp-v-highedge-v-high
*ramp-v-lowedge-v-low
bevel-lean
+
*tangent-ubevel-u
*tangent-vbevel-v

An inner corner is a crevice, and a crevice gathers occlusion. Only the filleted edges contribute; a rounded-over outer edge is more exposed than the face it belongs to, not less.

crease
+
*ramp-u-low
max0.0
-edge-u-low
*ramp-u-high
max0.0
-edge-u-high
*ramp-v-low
max0.0
-edge-v-low
*ramp-v-high
max0.0
-edge-v-high
seam
clampcrease0.01.0

One linear texture read replaces the four height taps formerly used here. The CPU generator clamps those taps within each tile, normalizes the result, and stores the source height in alpha for inspection. Fade the decoded tangent normal back toward the flat face as its texels become sub-pixel.

normal-sample
representation
swizzle
sampleblock-normal-atlasblock-sampleruv
:rgb
tangent-normal
-
*normal-sample2.0
vec31.01.01.0
relief-x
*
swizzletangent-normal:x
relief-fade
relief-y
*
swizzletangent-normal:y
relief-fade
relief-z
mix1.0
swizzletangent-normal:z
relief-fade

A real round-over is an arc, not a leaning plane. Adding a scaled tangent to the face normal can only ever reach the arctangent of that scale, so a strong edge needs an implausible scale and still bunches its shading into the last sliver. Rotating the normal through an angle instead sweeps it evenly from the face toward the edge, which is what a fillet of that radius actually does. The guarded denominator matters: over the flat middle of a face the lean is exactly zero, and the angle is zero with it.

lean-magnitude
sqrt
dotbevel-leanbevel-lean
lean-direction
/bevel-lean
max0.0001lean-magnitude
bevel-turn
*
min1.0lean-magnitude
*0.90bevel-fade
rounded
+
*flat-normal
cosbevel-turn
*lean-direction
sinbevel-turn
seam-occlusion
-1.0
*0.34
*seambevel-fade

--- the weathering ----------------------------------------------- The atlas has one tile per material and the world has thousands of faces per tile, so on its own every grass block is the same card seen again. Three fields fix that and none of them costs a texel: a hash of the cell a face belongs to, constant across that face and different on the next one; value noise over the world point itself, which knows nothing about where faces begin and so drifts across a whole plain the way real ground does; and a grain finer than a cell whose own gradient leans the surface, so the ground is not merely painted unevenly but lit unevenly. The cell is found by stepping half a cell back along the face's own normal, which lands inside the block whichever of the six faces this is. #3AVEKC

face-cell
floor
-surface-point
*flat-normal0.5
face-seed
lattice-hash
+face-cell
*flat-normal0.37
face-hue
lattice-hash
+face-cell
vec35.211.379.13
patch
lattice-fractal-noise
*surface-point0.075
grain-point
*surface-point1.05
grain
lattice-noisegrain-point

Two more taps of the same grain, a step along each of the face's own in-plane axes: their differences are the field's gradient there, which is exactly the tilt a bump of it would give.

grain-u
lattice-noise
+grain-point
*tangent-u0.55
grain-v
lattice-noise
+grain-point
*tangent-v0.55
weathering
clamp
*surface-detail
+
*
-patch0.5
1.05
+
*
-face-seed0.5
0.30
*
-grain0.5
*0.30bevel-fade
-0.420.42

Brighter patches are also warmer and duller ones cooler, and a face's own constant drifts its hue as well as its value: a plain of grass wants to be many greens, not one green at many brightnesses.

hue-drift
clamp
*surface-detail
+
*
-face-hue0.5
0.36
*
-patch0.5
0.44
-0.350.35
weathered-tint
+
vec31.01.01.0
+
*
vec31.121.00.84
weathering
*
vec30.130.02-0.11
hue-drift

The grain is a cell-wide feature, so it survives to the distance a cell does rather than the distance a texel does.

bump-strength
*0.85
*bevel-fadesurface-detail
shaped
normalize
+
+
*roundedrelief-z
*tangent-u
+relief-x
*
-graingrain-u
bump-strength
*tangent-v
+relief-y
*
-graingrain-v
bump-strength
shading-normal
assume-quantityshaped:quantity:world-direction:unit:one

--- the micro-surface -------------------------------------------- The atlas normal is this material's own slope at texel scale, and the best evidence available about what happens below a texel: a steep texel is a rough material. When the relief fades out with distance its variance has to go somewhere, and the honest place is the roughness -- Toksvig's argument -- so a highlight keeps its size across the draw distance instead of sharpening into a sparkle on every distant face at once. #2T8CCH

relief-slope
sqrt
+
*
swizzletangent-normal:x
swizzletangent-normal:x
*
swizzletangent-normal:y
swizzletangent-normal:y
roughness
clamp
*surface-roughness
+
mix0.400.92
clamp
*relief-slope1.7
0.01.0
+
*0.28
-1.0relief-fade
*0.10
-grain0.5
0.160.99
sun-direction
swizzlesun-vector:xyz
n-dot-l
max0.0
dotshading-normalsun-direction
shadow-coordinateshadow-uv-input
shadow-u
swizzleshadow-coordinate:x
shadow-v
swizzleshadow-coordinate:y
shadow-in-bounds
*
step
quantity0.0:quantity:shadow-u:unit:one
shadow-u
stepshadow-u
quantity1.0:quantity:shadow-u:unit:one
step
quantity0.0:quantity:shadow-v:unit:one
shadow-v
stepshadow-v
quantity1.0:quantity:shadow-v:unit:one
shadow-texel-size
swizzleshadow-control-vector:xy
shadow-base-bias
swizzleshadow-control-vector:z
shadow-slope-bias
swizzleshadow-control-vector:w

A PCF tap displaced across a receiver plane must compare against that plane's depth at the tap, not the center fragment depth. With the orthographic shadow rows, the analytical UV depth gradient is the face normal expressed in the light basis and scaled by the world-span/depth-span ratio. Its denominator is bounded only near grazing incidence, where direct sun is negligible. This prevents wide kernels from reading the receiver's own slope as an occluder.

shadow-right
assume-quantity
normalize
swizzleshadow-row-x:xyz
:quantity:world-direction:unit:one
shadow-up
assume-quantity
normalize
swizzleshadow-row-y:xyz
:quantity:world-direction:unit:one
shadow-forward
assume-quantity
normalize
swizzleshadow-row-z:xyz
:quantity:world-direction:unit:one
shadow-normal-forward
min-0.05
dotnormalshadow-forward
shadow-depth-span
swizzleshadow-filter-vector:x
shadow-world-units-per-texel
swizzleshadow-filter-vector:y
shadow-world-span
interpret
/shadow-world-units-per-texel
swizzleshadow-texel-size:x
:quantity:world-distance:unit:cell
shadow-span-ratio
/shadow-world-spanshadow-depth-span
shadow-depth-gradient
interpret
vec2
-
assume-quantity
representation
*
/
dotnormalshadow-right
shadow-normal-forward
shadow-span-ratio
:quantity:shadow-depth-gradient:unit:one
-
assume-quantity
representation
*
/
dotnormalshadow-up
shadow-normal-forward
shadow-span-ratio
:quantity:shadow-depth-gradient:unit:one
:quantity:shadow-depth-gradient:unit:one
shadow-center-depth
swizzle
sampleshadow-mapshadow-samplershadow-coordinate
:x
receiver-depthshadow-depth-input
shadow-blocker-separation
interpret
max
quantity0.0:quantity:shadow-depth:unit:one:character:difference
-
-receiver-depthshadow-bias
shadow-center-depth
:quantity:shadow-depth:unit:one:character:absolute
shadow-minimum-radius
swizzleshadow-filter-vector:z
shadow-maximum-radius
swizzleshadow-filter-vector:w
sun-angular-width
swizzlesun-color-vector:w
shadow-penumbra-world-radius
interpret
*
*shadow-blocker-separationshadow-depth-span
sun-angular-width
:quantity:world-distance:unit:cell
shadow-filter-radius
clamp
+shadow-minimum-radius
interpret
/shadow-penumbra-world-radiusshadow-world-units-per-texel
:quantity:shadow-filter-radius:unit:one
shadow-minimum-radiusshadow-maximum-radius
shadow-sample
shadow-visibilityshadow-mapshadow-comparison-samplershadow-coordinatereceiver-depthshadow-depth-gradientshadow-texel-sizeshadow-biasshadow-filter-radius
sampled-shadow
mix1.0shadow-sampleshadow-in-bounds

A surface turned away from the light has no shadow decision to make: its own geometry already excludes the sun, the receiver plane's depth runs almost parallel to the light so every filter tap lands a large and increasingly ill-conditioned distance away, and the map's answer degenerates into noise. Near noon that is every vertical face in the world at once, which is why the shimmer was worst there. Fading the decision out toward "lit" exactly where it stops meaning anything costs nothing visually -- the same cosine multiplies the direct term to zero -- and gives the specular lobe and the diagnostic a stable value instead of the noise.

The lower edge is where the receiver-plane denominator above starts being clamped: SHADOW-FORWARD is the negated sun, so that dot product is exactly -N-DOT-L, and the clamp engages precisely below 0.05. The two mechanisms therefore hand over rather than overlap, and the clamp never has to carry a visible decision.

shadow-relevance
smoothstep0.050.22n-dot-l
direct-shadow
mix1.0sampled-shadowshadow-relevance

The mesh carries normalized raw light readings; every response curve and balance below is an art parameter editable live without remeshing the world.

sky-input
swizzlelight-input:x
block-input
swizzlelight-input:y
emission-input
swizzlelight-input:z
sky-level
*sky-inputsky-input
block-level
*block-inputblock-input
day-factor
swizzlesun-vector:w

Lateral skylight gives ambient visibility but not a hard sun beam; the shadow map gates only the direct solar term.

sun-visibility
smoothstep
quantity0.90:quantity:sky-light-level:unit:one
quantity1.0:quantity:sky-light-level:unit:one
sky-input
ambient
swizzleambient-vector:xyz
sun-color
swizzlesun-color-vector:xyz

--- the deck's shadow -------------------------------------------- The sky draws a cumulus deck on a plane at a fixed world height, and the ground under it should know. Following the sun up from this point to that plane and asking the very same field the very same coverage question is the whole of it: the deck's own shadow sweeping across the world, which is what keeps a plain at noon from being one flat sheet of light. The threshold is wider than the sky's, because a shadow thrown from that far off is blurred by the sun's own angular width long before it lands. #RSGLTL

elapsed
sun-climb
max0.15
representation
swizzlesun-direction:y
deck-reach
/
max8.0
-cloud-altitude
swizzlesurface-point:y
sun-climb
deck-meeting
+
+surface-point
*
representationsun-direction
deck-reach
vec3
*elapsed2.2
0.0
*elapsed1.2
deck-cover
cloud-fractal-noise
*deck-meeting0.0044
deck-coverage
cloud-shadow
assume-quantity
-1.0
*cloud-shadow-depth
smoothstepdeck-coverage
+deck-coverage0.20
deck-cover
:quantity:cloud-shadow:unit:one

Occlusion bites harder than the raw mesh reading: the corner where three blocks meet is what tells the eye these are solid volumes.

occlusion
interpret
*
exptao1.8
assume-quantityseam-occlusion:quantity:ambient-occlusion:unit:one
:quantity:ambient-occlusion:unit:one

--- what is not the sun ------------------------------------------ Everything above a face that is not the sun is still the sky, and the sky is not one colour: a face turned up sees the zenith, a face turned sideways sees the horizon, and a face turned down sees what the ground bounced. One interpolation over the normal's own vertical component is the whole of it, and it is the difference between blocks standing in a world and blocks floating in a grey room. The profile's ambient colour stays in the mixture: it is the art direction's say over light the geometry cannot explain.

facing-up
*0.5
+1.0
representation
swizzleshading-normal:y
dome-color
mix
representation
swizzlehorizon-vector:xyz
representation
swizzlezenith-vector:xyz
*facing-upfacing-up
bounce-color
environment
assume-quantity
mixbounce-color
mixdome-color0.62
facing-up
:quantity:linear-rgb:unit:one

A small floor keeps unlit geometry barely readable rather than a void; caves stay dark for the right reason. The ambient term is deliberately weaker, and the sun correspondingly stronger, than a display-referred renderer could afford: the filmic curve on presentation is what brings the sunlit half back down.

sky-light
interpret
*environment
+0.030
*0.86sky-level
occlusion
:quantity:linear-rgb:unit:one
sun-light
interpret
*sun-color
*direct-light-gainn-dot-lsun-visibilityday-factordirect-shadowcloud-shadow
mix0.551.0ao
:quantity:linear-rgb:unit:one
torch-color
quantity
vec31.00.820.58
:quantity:linear-rgb:unit:one
local-light
interpret
*torch-colorblock-level
:quantity:linear-rgb:unit:one
albedo
assume-quantity
*
representation
swizzle
sampleblock-atlasblock-sampleruv
:rgb
weathered-tint
:quantity:linear-rgb:unit:one
reflected
interpret
*albedo
+sky-lightsun-lightlocal-light
:quantity:linear-rgb:unit:one

--- the microfacet lobe ------------------------------------------ One GGX lobe off the shaped normal, with Smith's height-correlated visibility and a dielectric's Fresnel: blocks are not metal, so four per cent at normal incidence and the whole of it at a grazing one. The roughness above decides everything about its shape, so a polished face and a tufted one differ here without differing anywhere else.

view-direction
assume-quantity
normalize
-eyesurface-point
:quantity:world-direction:unit:one
half-vector
assume-quantity
normalize
+
normalize
-eyesurface-point
representationsun-direction
:quantity:world-direction:unit:one
n-dot-h
max0.0
dotshading-normalhalf-vector
n-dot-v
max0.0
dotshading-normalview-direction
v-dot-h
max0.0
dotview-directionhalf-vector
cosine-light
cosine-view
cosine-half
alpha
*roughnessroughness
alpha-squared
*alphaalpha
distribution-denominator
+
*
*cosine-halfcosine-half
-alpha-squared1.0
1.0
distribution
/alpha-squared
max0.0001
*3.14159265
*distribution-denominatordistribution-denominator

Smith's height-correlated visibility already carries the 1/(4 cos cos) the microfacet specular would otherwise divide by.

visibility-light
*cosine-view
sqrt
+
*
*cosine-lightcosine-light
-1.0alpha-squared
alpha-squared
visibility-view
*cosine-light
sqrt
+
*
*cosine-viewcosine-view
-1.0alpha-squared
alpha-squared
visibility
/0.5
max0.0001
+visibility-lightvisibility-view
fresnel
+0.04
*0.96
expt
-1.0
5.0

The cosine belongs in the specular lobe as much as in the diffuse one: without it a highlight can appear on a face the sun cannot reach, carrying whatever the shadow map happened to say there.

sun-specular
*
*
*distribution
*visibilityfresnel

The same sky again, in the direction the surface actually reflects: the sheen that tells a smooth face from a rough one out of the sun, and the one term that makes a block look like it is standing under this weather rather than under a light bulb.

reflection-direction
-
*
representationshading-normal
*2.0cosine-view
representationview-direction
reflection-up
clamp
*0.5
+1.0
swizzlereflection-direction:y
0.01.0
reflected-sky
mix
representation
swizzlehorizon-vector:xyz
representation
swizzlezenith-vector:xyz
*reflection-upreflection-up
sheen
*
-1.0roughness
+0.035
*0.32
expt
-1.0cosine-view
5.0
ambient-specular
*reflected-sky
*sheen
specular
assume-quantity
+sun-specularambient-specular
:quantity:linear-rgb:unit:one
radiance
+reflectedspecular
interpret
*albedoemission-input
:quantity:linear-rgb:unit:one

Distant terrain fades into exactly the colour the sky's own ground half arrives at, warmed where the ray runs toward a low sun: the aerial perspective that puts a mile of air between here and the horizon.

look-direction
normalize
-surface-pointeye
sun-elevation
representation
swizzlesun-direction:y
low-sun
*
-1.0
smoothstep0.020.45sun-elevation
fog-color
assume-quantity:quantity:linear-rgb:unit:one
fog-amountfog-input
fogged
mixradiancefog-colorfog-amount
normal-rgba
assume-quantity
vec4
representation
quantity1.0:quantity:opacity:unit:one
:quantity:linear-rgba:unit:one
shadow-diagnostic
swizzlefog-color-vector:w
shadow-rgba
assume-quantity
vec4
representation
vec3direct-shadowdirect-shadowdirect-shadow
representation
quantity1.0:quantity:opacity:unit:one
:quantity:linear-rgba:unit:one
rgba
mixnormal-rgbashadow-rgbashadow-diagnostic
set-outputcolor-outputrgba
define-shader-method shader-specification-for shaders.lisp:2031

The sky is image mathematics over a view ray and the frame environment. What it argues is that everything up there is one atmosphere seen at different depths:

  • the vertical gradient runs the whole way from horizon to zenith

rather than resolving in the first few degrees, because that is what the optical depth along a ray actually does;

  • haze thickens toward the horizon, and the sunrise band it carries

belongs to the sun's own quarter of the compass, not all the way round;

  • one Henyey-Greenstein phase function, evaluated at two asymmetries,

is the whole glow around the sun;

  • two cloud decks, each a plane at a fixed height, so the ray meets

them farther out the closer it runs to level: thin cirrus far above, and the cumulus sheet whose own shadow, sampled once along the deck toward the sun, gives it a lit face and a dark underside;

  • at night, points for stars, a band for the galaxy, and the moon

opposite the sun;

  • a compact solar disc pushed well past display white so the lens

chain has something real to bloom.

Everything below the horizon arrives at the exact fog colour distant terrain fades to, so silhouettes meet the sky without a seam, and only then darkens -- gently, because a stronger falloff shows up as a step where the last resident chunk ends. #9SSXDJ records why an HDR path wants a sky with something genuinely bright in it, and #8X33G2 what each of these terms replaced.

define-shader-methodshader-specification-forblock-world-sky-fragment-specification
role
eql:sky
stage
eql:fragment
:stage:fragment:inputs
ray-input:vec3:location0:quantity:world-direction:unit:one
:outputs
color-output:vec4:location0
:resources
frame-state:uniform-block:set0:binding2:members#.*frame-uniform-members*
let*
direction
elevation
swizzledirection:y
above
maxelevation0.0
eye
representation
swizzlecamera-vector:xyz
sun-direction
representation
swizzlesun-vector:xyz
sun-color
representation
swizzlesun-color-vector:xyz
sun-width
representation
swizzlesun-color-vector:w
zenith
representation
swizzlezenith-vector:xyz
horizon
representation
swizzlehorizon-vector:xyz
fog-color
representation
swizzlefog-color-vector:xyz
elapsed

One number decides how much of the sky is sunrise: a sun near the horizon warms the haze, the scatter, and the cloud faces together.

sun-elevation
swizzlesun-direction:y
low-sun
*day-factor
-1.0
smoothstep0.020.45sun-elevation
alignment
dotdirectionsun-direction
toward-sun
max0.0alignment

The sun's half of the sky, measured on the ground plane: a sunrise band belongs in the east, and the west should stay blue.

level-ray
vec3
swizzledirection:x
0.0
swizzledirection:z
level-sun
vec3
swizzlesun-direction:x
0.0
swizzlesun-direction:z
azimuth
max0.0
/
dotlevel-raylevel-sun
max0.001
*
sqrt
dotlevel-raylevel-ray
sqrt
dotlevel-sunlevel-sun

--- the atmosphere --------------------------------------------- The vertical gradient is the sky's own colour at depth; a small exponent spreads it across the whole hemisphere instead of resolving it in the first few degrees, which is what an optical depth along the ray actually does.

gradient
expt
clampelevation0.01.0
0.42
base
mixhorizonzenithgradient
haze
exp
*-9.0above

A sunrise is sunlight that has come the long way through the atmosphere, so the band it paints is the sun's own colour at that hour, laid exactly where the ray runs both low and toward it. Away from the sun's quarter of the compass the sky stays its own colour, which is what keeps the west blue while the east burns.

warm-color
*sun-color0.82
warm-band
*low-sun
*haze
+0.12
*0.88
*azimuthazimuth
hazed
mixbasewarm-color
clamp
*0.92warm-band
0.01.0
broad
henyey-greensteinalignment0.62
tight
henyey-greensteinalignment0.90
halo-tint
mix
vec31.00.970.90
vec31.00.520.24
low-sun
halo-depth
mix0.701.60haze
halo
*
+
*broad
+0.030
*0.055low-sun
*tight
+0.014
*0.055low-sun
scattered
+hazed
*halo-tinthalo

--- night ------------------------------------------------------

night

The galaxy is a great circle of the sky, so one fixed axis and the ray's distance from its plane is the whole band.

galaxy-axis
vec30.420.55-0.72
galaxy-distance
dotdirectiongalaxy-axis
galaxy-band
exp
*-20.0
*galaxy-distancegalaxy-distance
galaxy-structure
+
*
lattice-noise
*direction15.0
0.58
*
lattice-noise
*direction44.0
0.42
galaxy
*galaxy-band
*
+0.10
*1.25galaxy-structure

A dust lane is the band's own darkness, not an absence of stars, so it multiplies rather than subtracts.

-1.0
*0.55
smoothstep0.420.66
lattice-noise
*direction7.0
star-visibility
*night
smoothstep-0.020.14elevation
stars
*
star-lightdirectionelapsed
*star-brightness
*star-visibility
+1.0
*1.6galaxy-band
starred
+
*
vec30.600.660.94
*galaxy
*star-visibility0.17
*
vec30.920.941.0
stars

The moon rides opposite the sun, which puts it up for exactly the hours the sun is not, and always full: the simple sky this world wants, and the one that lights its nights.

moon-direction
*sun-direction-1.0
moon-alignment
dotdirectionmoon-direction
moon-radius
*sun-width1.7
moon-limb
*0.5
*moon-radiusmoon-radius
moon-disc
smoothstep
-1.0moon-limb
-1.0
*0.80moon-limb
moon-alignment

The component of the ray across the moon's own direction, in units of its radius: the disc's face, which the maria are painted on.

moon-face
/
-direction
*moon-directionmoon-alignment
max0.0001moon-radius
moon-maria
lattice-noise
+
*moon-face1.8
vec313.05.09.0
moon-shape
*
+0.70
*0.50moon-maria
-1.0
*0.40
clamp
dotmoon-facemoon-face
0.01.0
moon-glow
*
expt
max0.0moon-alignment
220.0
0.30
lunar
*
vec30.940.951.0
*night
+
*moon-disc
*moon-radiancemoon-shape
moon-glow
nightly
+scattered
+starredlunar

--- the cloud decks --------------------------------------------

cirrus-point
+
*direction
/2400.0
maxelevation0.02
vec3
*elapsed6.0
0.0
*elapsed2.0
cirrus-field
lattice-fractal-noise
*cirrus-point
vec30.001050.001050.00225
cirrus
*
smoothstep0.560.88cirrus-field
*
smoothstep0.020.26elevation
*
-1.0
0.45
cirrus-color
*
mix
vec31.101.121.18
vec31.380.840.56
*low-sunlow-sun
with-cirrus
mixnightlycirrus-colorcirrus

The deck is a plane at a fixed height in the world, not a fixed height above the camera: the ground has to be able to find the same plane along the sun and read the same field there, or the shadows sweeping over it would belong to some other sky. #RSGLTL

deck-rise
deck-point
+
+eye
*direction
/deck-rise
maxelevation0.014
vec3
*elapsed2.2
0.0
*elapsed1.2
deck-scale0.0044
cloud-field
cloud-fractal-noise
*deck-pointdeck-scale

Coverage is the profile's cloudiness against the knob; the edge softens toward the horizon, where a deck's detail is far smaller than a pixel and a hard edge could only shimmer.

coverage
softness
mix0.260.055
smoothstep0.0120.34elevation
deck-mask
smoothstep0.0060.055elevation
cloud-density
*deck-mask
smoothstepcoverage
+coveragesoftness
cloud-field
core
clampcloud-density0.01.0

What lies between this piece of deck and the sun, sampled along the deck itself: the shape's own shadow, and so its lit face.

shadow-field
cloud-fractal-noise
*
+deck-point
*level-sun300.0
deck-scale
shadow-density
smoothstepcoverage
+coveragesoftness
shadow-field
cloud-light
-1.0
*0.70shadow-density
cloud-lit
mix
vec31.221.201.16
vec31.400.740.36
*low-sunlow-sun
cloud-dark
mix
vec30.500.570.74
vec30.440.380.52
*low-sunlow-sun
cloud-body
mixcloud-darkcloud-lit
*cloud-light
-1.0
*0.45core

Silver lining: thin edges facing the sun glow, dense cores do not.

silver
*cloud-lit
*
expttoward-sun14.0
*
-1.0core
+0.30
*0.85low-sun

After sunset a deck is lit by the sky alone, so it takes the sky's own colour; a neutral grey at one twentieth reads warm against a night zenith and the eye calls it dust.

night-tint
mix
vec30.440.520.80
vec31.01.01.0
cloud-color
*
*
+cloud-bodysilver
night-tint

A deck recedes into the same haze the sky's own horizon does.

cloud-reach
smoothstep0.0100.11elevation
clouded
mixwith-cirrus
mixhazedcloud-colorcloud-reach
*cloud-density0.94

--- the ground half -------------------------------------------- Below the horizon this shader stands in for terrain too far off to be resident, so it must arrive at exactly the colour distant terrain fades to. It may still say where the land is: the ray's own meeting with the ground plane gives a point to sample, and a wide, weak relief over it keeps the lower half from being one flat wall of fog. The relief fades out at the horizon, where the plane runs away faster than a pixel can resolve it.

aerial
aerial-perspective-colorfog-colordirectionsun-directionlow-sunday-factor
descent
max0.004
-elevation
ground-point
+eye
*direction
/
max1.0
swizzleeye:y
descent
land
lattice-fractal-noise
*ground-point0.0021
land-relief
*
-land0.5
smoothstep0.0-0.22elevation
depth-below
smoothstep0.0-0.35elevation
ground
*aerial
+
-1.0
*0.26depth-below
*0.44land-relief
grounded
mixcloudedground
smoothstep0.060-0.006elevation

--- the sun ---------------------------------------------------- The disc is drawn at a few times the sun's true angular radius, the way every game sun is, and deliberately far above display white: the floating point attachment keeps it, the bright pass feeds on it, and the filmic curve rolls it into a hot core instead of a flat clipped patch. For a small angle the cosine threshold of an angular radius is one less half its square.

disc-radius
disc-limb
*0.5
*disc-radiusdisc-radius
disc
smoothstep
-1.0disc-limb
-1.0
*0.56disc-limb
alignment

A star's disc is brightest at its centre; without the limb darkening a sun drawn this large reads as a sticker.

disc-radial
clamp
/
-1.0alignment
max0.000001disc-limb
0.01.0
limb-darkening
-1.0
*0.45
*disc-radialdisc-radial
corona
+
*
expttoward-sun900.0
0.8
*
expttoward-sun130.0
0.22
occlusion
-1.0
*core0.94
solar
*sun-color
*day-factor
+
*disc
*sun-disc-radiance
*occlusionlimb-darkening
*corona
*2.0occlusion
rgb
+groundedsolar
set-outputcolor-output

Later: real-time shadows, not baked animated sunlight #SKLATR

If trees and terrain need shadows which rotate with the sun, add a GPU shadow map (or another explicitly evaluated real-time technique). Do not bake a directional term into chunk meshes and invalidate the whole resident world every time the clock moves. The voxel skylight field remains useful ambient visibility underneath that future pass.

The core shader language should stay mathematical. A word such as shadow-visibility belongs in a shader-abstraction layer or in luvcraft material vocabulary, where it can expand into ordinary operations: sample a depth image, select a lane, compare receiver depth with a bias, and filter neighboring taps when PCF becomes useful. This keeps the same abstraction available to a directional sun, a spot torch, or an eventual point-light atlas without making any one light shape a compiler primitive.

Other later capabilities should be earned by a concrete scene: coloured blocklight needs three propagated channels; translucent foliage needs opacity values between air and stone plus suitable material blending; clouds need a separate sky layer. None requires replacing the environment/derived-field split above.

Verification and observability #SKTEST

The strongest lighting test is differential: generate finite random worlds including multiple vertical chunks, apply random content edits and arrival/departure sequences, settle the incremental queues, and compare every resident sky/block value with a from-scratch solve using the same explicit boundary conditions.

Targeted tests cover:

Runtime counters exposed to SLY report lighting queue sizes and cell visits, candidate age/restarts, chunks and bytes touched, light publication latency, remesh latency, mesh bytes, and relevant frame CPU time. Performance claims come from those counters and repeated captures, not estimates in this page.

Visual review uses the actual moving renderer at pinned noon, dusk, and night: walk from sunlight into a cave, move under and around trees, cross residency edges, and place/remove a crystal on both sides of a chunk seam. The ordinary verification loop remains:

make test
make smoke
scripts/luv gazetteer build/gazetteer --view glow-floor
md5 -q build/luvcraft-smoke.png

Every shader module added by a phase also passes spirv-val. A visual phase changes the smoke hash deliberately only after inspecting the image; a fixed clock then keeps the new hash stable.

Playable implementation sequence #SKPHAS

  1. Shader mathematics. Done: extended-instruction structure, typed math operators, clamped existing fog, uniform-size validation, spirv-val, and live replacement proof.
  2. Sky. Done: session clock/profile, shared frame parameters, fullscreen live sky pipeline, animated material/fog, and pinned deterministic captures.
  3. Skylight. Done as a first CPU proof: light-field objects and revisions, explicit boundary/source semantics, reference and incremental solvers, snapshot light halo, raw vertex light, and caves/canopies visible in tests.
  4. Crystal. Emission/opacity block vocabulary, propagated blocklight, dynamic palette keys, and measured edit-to-photon behavior.
  5. HDR and bloom. Linear scene target, tone mapping, then a restrained crystal bloom with explicit resource lifetime and resize behavior.
  6. Real-time shadows. Done as a first credible pass: shader abstractions, separate GPU depth, stable projection, filtered receiver sampling, and isolated plus consecutive-play evidence. Animated light remains outside mesh products.

Each checkpoint is useful on its own. The sequence deliberately builds general shader and lighting capabilities where the feature needs them, while keeping the frame sequence, ownership boundaries, dense hot data, and live development behavior direct and inspectable.

Light work marks #WKLITE

These are the current work marks for the lighting path. They use the wiki's work-mark convention #W3K9MK: each item is a figure with a small status, intent, evidence, and done condition.

DONE First CPU voxel-light proof #WLDONE

Intent: make sky/block light a derived chunk field with its own revisions, incremental reconciliation, raw mesh transport, and shader-side response curves.

Evidence:

  • luvcraft/light.lisp owns reference and incremental solvers.
  • luvcraft/mesher.lisp snapshots a one-cell light halo and emits the (sky block emission) attribute.
  • luvcraft/light-tests.lisp checks reference equivalence, mesh light transport, and same-key chunk replacement before reconciliation.

Done when: :luvcraft tests pass and the wiki describes the implemented Phase 0--2 state rather than the pre-implementation plan.

DONE Player-placeable crystal #WLCRYS

Intent: turn the test-only glow block into a real material the player can select, place, remove, and inspect in motion.

Evidence:

  • luvcraft/blocks.lisp defines public *crystal-block* with atlas tile 9, propagated light emission 12, and surface emission 1.2.
  • luvcraft/app.lisp and luvcraft/render.lisp derive selection titles and number-key bounds from placeable-block-kinds.
  • luvcraft/tests.lisp covers palette slot 8, key selection, title text, atlas size, and the crystal material readings.
  • luvcraft/light-tests.lisp covers crystal placement/removal across a chunk boundary, reference-field convergence, affected light revisions, and mesh emission.
  • The gazetteer glow-floor view showed the crystal face glowing and warming nearby floor surfaces.

Done when: a pinned-night capture shows the crystal face glowing and warming nearby surfaces, while tests cover placement/removal and cross-chunk falloff.

TODO Measure light-to-mesh latency #WLLATM

Intent: find out whether owner-thread relighting plus one mesh capture per frame feels immediate enough during edit bursts and residency churn.

Evidence to gather:

  • SLY-visible counters or capture logs report relight cells popped, chunks touched, light publication latency, remesh latency, and mesh bytes.
  • A repeatable edit/residency script exercises a worst-plausible visible burst without relying on hand timing.

Done when: the next scheduling decision is evidence-backed: keep owner-thread lighting, tune frame limits, or move captured light work into the existing production system.

DONE Shadow-ready shader math substrate #H9Q1Q0

Intent: prepare the core shader language for shadow-map expressions without adding a high-level shadow primitive. The core vocabulary should remain ordinary typed mathematics and resource sampling.

Evidence:

  • Commit 2d22815 adds spv:step through the existing EQL operator protocol.
  • shader-type records sample result and depth-image metadata; the expression language accepts :depth-texture-2d as a typed resource.
  • sample still lowers as ordinary OpImageSampleImplicitLod; depth samples remain vec4 in valid SPIR-V and feed scalar depth math through swizzle.
  • hal/shader/tests.lisp covers a depth-texture sample, swizzle :x, and step as the comparison-shaped math a future shadow abstraction can expand to.
  • make test and an explicit spirv-val --target-env vulkan1.0 check on the depth-sampling fragment module passed.

Done when: a shader can express the depth-read and receiver-depth comparison pieces of shadow visibility using only core mathematical operators.

DONE Shader source abstractions for reusable shadow expressions #DXYHT4

Intent: add a source-expansion layer above the core shader operator set, then define shadow vocabulary there instead of teaching the compiler a special shadow primitive.

Evidence:

  • define-shader-abstraction adds an open EQL-method source vocabulary beside the core define-shader-operator protocol; expand-shader-source-form rewrites abstractions before ordinary expression parsing.
  • shadow-depth-test is the one-sample abstraction over step and a biased receiver depth. shadow-visibility is also source vocabulary, composing a 3x3 grid of those tests. Neither is a shader operator or a special lowering primitive.
  • hal/shader/tests.lisp proves the expanded graph lowers through OpImageSampleImplicitLod plus the existing extended step path, that abstraction redefinition affects fresh parses, and that a bad expansion signals during parsing while already parsed graphs remain ordinary data.
  • make test passed.

Done when: shader authors can define reusable shadow source vocabulary that compiles to the same mathematical expression graph as handwritten core shader forms.

DONE Shadow-map producer pass and light projection resources #2WX9PW

Intent: make the renderer capable of producing a shadow depth map as a separate GPU product owned by the luvcraft session, with light-space projection data carried through the ordinary frame uniform.

Evidence:

  • The Vulkan backend accepts a depth-only render pass and a vertex-only graphics pipeline; the depth attachment can be stored instead of discarded.
  • The live pipeline artifact can now watch a :vertex shader role directly, so the shadow pass remains hot-reloadable through the same CLOS/MOP path as visible materials.
  • luvcraft owns a 1024x1024 :depth32-float shadow texture, view, sampler, and :block-shadow pipeline, and encodes a shadow pass before the normal sky/material/crosshair pass.
  • The shared frame uniform grew by four light-space rows, and block-world-shadow-vertex-specification projects mesh positions with dot products against those rows.
  • make test passed; an explicit spirv-val --target-env vulkan1.0 check on build/block-world-shadow.vert.spv passed; make smoke rendered a hidden frame successfully.

Done when: the frame produces a stored shadow depth map from block geometry without changing visible lighting yet; visible sampling moves to #MFBOFD.

DONE Sample the shadow map in the block material #MFBOFD

Intent: consume the shadow depth map from the block material using the shadow-visibility abstraction, so moving sun light gets real direct shadows while voxel skylight remains the ambient term.

Evidence:

  • The Vulkan bind-group path now accepts multiple textures, samplers, and buffers in one layout, so luvcraft can bind the atlas, shadow depth map, and frame uniform without a one-texture special case.
  • The block vertex shader exports light-space shadow UV and receiver depth to the fragment shader using the frame uniform's shadow rows; the core shader DSL still only sees ordinary vector math and typed resources.
  • The block fragment shader multiplies only the direct sun term by shadow-visibility; ambient skylight and blocklight keep their current semantics.
  • The first smoke capture was not strong enough evidence: it mostly proved the frame survived. The follow-up shadow-yard gazetteer view now isolates a bright snow receiver, a tall stone pillar, a low bright sun, and a high-contrast sky profile; a shadow-disabled control made the earlier over-shadowing failure obvious.
  • The light-space forward axis now points from the sun toward the world. With that sign fixed, build/gazetteer/shadow-yard.png shows the pillar casting a long, dark, pillar-shaped shadow across the receiver.
  • make test, make smoke, and scripts/luv gazetteer build/gazetteer --view shadow-yard passed.

Done when: a pinned scene shows moving geometry casting real-time direct-light shadows through the shader abstraction, with all new textures, bind groups, and pipelines retiring through the existing completion frontier.

DONE First isolated visual evidence standard for shadow changes #0BHYRT

Intent: keep shadow work from passing on screenshots that are merely plausible or too dark to inspect. This was the first gate; #CH2CD0 records why it was necessary but insufficient for representative-play acceptance.

Evidence:

  • A shadow-map change needs at least one pinned gazetteer view whose scene is composed for inspection: bright receiver, isolated caster, stable camera, known sky/direct-light inputs, and enough contrast that the cast shape is obvious without zooming.
  • A smoke capture can remain a survival check, but it is not evidence that cast shadows are correct unless the shadow shape is obvious in frame.
  • When the visual result is ambiguous, capture a control image with shadow sampling disabled or otherwise isolated, then compare the receiver region before accepting the implementation.

Done when: future shadow iterations cite the pinned view and inspection condition they used, not just the existence of a rendered PNG.

DONE Shadow credibility in representative play #CH2CD0

Intent: replace the weak inference "the isolated yard contains a cast shadow, therefore shadows are good" with separate, falsifiable evidence for shadow geometry and for shadow behavior in the moving block world.

Evidence:

  • scripts/luv gazetteer build/gazetteer-review --view shadow-yard now exits normally and reproduces the analytic 960x640 caster/receiver image. The long pillar shadow keeps its expected direction and contact while the edge is filtered instead of a raw depth interpolation.
  • shadow-forest is generated terrain at 1512x982. The command scripts/luv gazetteer build/gazetteer-review --view shadow-forest --count 4 --forward-step 0.2 captures four neighboring camera positions from one live session. Inspection of frames 000--003 found the tree and terrain shadows anchored across movement, with no projection jump or detached contact. The bright snow remains clipped by the known LDR path; that is evidence for #WLHDRB, not a shadow-map success claim.
  • At this checkpoint the 1024x1024 map covered a 128-world-unit square, so one texel was 0.125 world units. The light-space footprint snaps to that grid. A 3x3 PCF kernel uses explicit nearest depth reads and a receiver bias of 0.00045 plus 0.0015 * (1 - n-dot-l), replacing the coarse constant 0.003 bias.
  • A 60-frame hidden run at 1512x982 measured 10.81 ms/frame wall time with 3x3 PCF. A one-tap abstraction in a separate fresh process measured 10.90 ms/frame. At this workload the difference is below run noise; these are end-to-end frame-loop timings, not GPU-only measurements.
  • luvcraft/tests.lisp proves that a 0.01-world-unit camera translation leaves the first two light-space rows unchanged while a 0.25 translation crosses the snap grid. hal/shader/tests.lisp proves nine depth samples and valid SPIR-V.

Done when: both the isolator and representative moving-view evidence are credible, the documented CLI can reproduce them without hanging, and the implementation has an explicit stable projection, filtering, and bias rule.

DONE Tool and live-definition hardening after the shadow review #9250U7

Intent: remove the review findings which made the shadow evidence or live shader story less trustworthy than their documentation claimed.

Evidence:

  • The tools entrypoint now leaves the process main thread to Cocoa and runs dispatch on a worker, matching the executable. The formerly hanging documented scripts/luv gazetteer path completed for both single and consecutive captures.
  • Parinfer now models vertical-bar symbols and single escapes; its unit tests cover |foo(bar|, foo\\(bar, an escaped bar, an unterminated bar, and an ordinary repair. Reader-valid escaped symbols are not rewritten.
  • Shader-producing methods reparse their small source form, abstraction definitions advance a locked revision, and each live artifact attempts that revision transactionally outside the MOP callback. A live GPU probe changed shadow-visibility and observed (:installed t t): installed status, advanced abstraction revision, and a replaced native pipeline.
  • make shader-validate now emits and validates the shadow vertex module. The generalized descriptor path no longer carries unused singular readers or six obsolete sampled-image/sampler compatibility wrappers.
  • make test, make smoke, strict repo-wide parinfer, and the documented gazetteer paths pass.

Done when: escaped symbols survive the checker, abstraction edits reach a live pipeline without sacrificing last-known-good behavior, every shader stage is validated, and the public capture path exits cleanly on macOS.

DONE Moving-sun shadow shape and temporal evidence #UDVPDW

Intent: make rotating sunlight read as a smooth depth cue during the ten-minute day without pretending that pinned-sun camera captures establish temporal quality. Preserve truthful block occlusion, but prefer a round, contact-aware filter footprint over visible square texel steps.

Evidence:

  • #CH2CD0 pins the sun at day fraction 0.42. It establishes camera-translation stability only; it does not exercise the light-space basis or rasterized silhouettes while the sun moves.
  • The earlier equal-weight 3x3 PCF kernel had a square footprint. Diagonal projected edges could therefore expose both the depth map's texel grid and the filter's axis alignment, especially as long sunset shadows moved.
  • shadow-frame-rows now uses world Y continuously. The tilted solar orbit never reaches that pole, so the former abs(forward.y) 0.92 reference-axis switch and its whole-map rotation are gone. A regression test compares the neighboring orientations through that old threshold.
  • The map is now 2048x2048 over the same 128-world-unit square, or 0.0625 world units per texel. shadow-visibility is a weighted 17-tap disk: a heavy inner ring and half-sector-rotated outer ring avoid reinforcing a square light-space border. Its two-to-six-texel radius grows from the center depth's receiver/blocker separation, the 192-unit light-depth span, and the sky profile's sun angular width.
  • This is deliberately a center-sample, PCSS-shaped approximation, not a full blocker-search PCSS implementation. A minimum two-texel footprint keeps lit-side edges filtered; the sampled separation makes deep projected canopy masses softer without inventing spherical caster geometry.
  • Consecutive capture accepts --day-start and --day-step. The command scripts/luv gazetteer build/shadow-dusk-review --view shadow-forest --count 6 --forward-step 0 --yaw-step 0 --day-start 0.70 --day-step 1/600 inspected a fixed camera at one-real-second-equivalent intervals in the ten-minute day. The long tree shadow swept as a cohesive soft mass without a projection pop. This sampling exposes gross discontinuities and changing silhouette shape; it does not by itself prove sub-frame temporal smoothness at display cadence.
  • The updated shadow-yard capture retained pillar contact, direction, and a readable far edge. make test passed with valid fragment SPIR-V and 17 explicit disk samples, and make smoke completed through the executable.
  • A 60-frame hidden run at 1512x982 measured 10.66 ms/frame wall time after one captured warm-up frame. The previous 3x3 checkpoint recorded 10.81 ms/frame, so this pair shows no obvious end-to-end regression; both are single-run wall measurements rather than an isolated GPU A/B.

Done when: the light basis remains continuous through the old threshold; the gazetteer can capture a fixed camera at explicit neighboring sun times; a rounder, distance-aware filter and any resolution change pass shader validation and are inspected in both the analytic yard and representative forest; and the wiki records what each capture can and cannot prove.

DONE Temporal derivatives and continuously filtered shadow decisions #GKEHPF

Intent: turn moving-shadow shimmer into inspectable temporal evidence, then remove discrete nearest-sampled comparison flips without adding a whole-scene temporal reconstruction system prematurely.

Evidence:

  • The 17-tap disk in #UDVPDW read nearest depth values and applied binary comparisons. Its weighted result therefore moved through discrete levels as a rotating silhouette crossed light-space texels; changing edge/lattice alignment accounts for the reported angle-dependent beat frequency without requiring a floating-point precision failure.
  • Linear filtering of raw depth is not the desired fix because interpolated depths across a discontinuity invent surfaces. The renderer now has a separate linear comparison sampler. Each sample-compare classifies the four neighboring depths and Vulkan bilinearly filters their visibility; ordinary nearest sampling remains available for the center blocker-distance estimate. The shader language lowers this explicitly to OpImageSampleDrefImplicitLod, and spirv-val accepts the fragment module.
  • Gazetteer sequences accept --difference-scale. They write amplified grayscale first and second derivatives plus unscaled normalized mean, maximum, and changed-pixel fractions in VIEW-temporal.csv. The first run immediately falsified its own fixed-world premise: a large derivative spike was terrain and trees publishing halfway through the sequence because the ordinary screenshot threshold waits for only nine useful products. Temporal capture now waits for every desired chunk product before frame zero.
  • The settled comparison used 60 fixed 960x640 frames, --day-start 0.70, --day-step 1/36000, and no camera motion: one display-frame-equivalent step for a ten-minute day. Against the otherwise identical nearest-comparison sequence, mean first-derivative magnitude fell from 0.00011173 to 0.00009798 (12.3 percent), and peak changed-pixel fraction fell from 0.02354 to 0.01829 (22.3 percent). Mean second derivative rose slightly, from 0.00022243 to 0.00023217, while its peak changed area fell from 0.04544 to 0.04367.
  • Those CSV values measure final 8-bit RGB, not isolated shadow visibility. The derivative plates reveal many one-code-value material and sky changes, so numerical second differences are a sensitive regression detector rather than a complete perceptual shimmer score. Inspection of the moving first and second derivatives shows less concentrated edge chatter, and the analytic shadow-yard still has attached pillar contact and a coherent penumbra.
  • This is spatially continuous comparison filtering, not temporal AA. Full TAA would require jitter, history buffers, motion/reprojection, disocclusion rejection, and a policy for moving block edits. The narrower change attacks the observed light-space texel transitions without adding that stateful reconstruction system.
  • Strict Parinfer, make test (including 17 comparison-sampled disk taps), fragment SPIR-V validation, the 60-frame runtime capture, and make smoke pass.
  • Immediate play after this checkpoint still showed substantial constant flicker. That falsifies any stronger perceptual reading of the final-colour averages above: this work established comparison filtering and temporal capture substrate, not a complete shimmer fix. #X9Q2YS isolates the shadow term and identifies the missed failure.

Done when: gazetteer capture can emit first and second temporal derivatives plus numerical summaries; a 60-Hz-equivalent fixed-camera sun sequence makes the shimmer measurable; the chosen sampling change reduces derivative chatter without detaching contact shadows; and tests, SPIR-V validation, smoke, and the wiki distinguish spatial comparison filtering from full temporal AA.

DONE Receiver-plane shadow evidence and self-shadow stability #X9Q2YS

Intent: explain and remove the constant frame-by-frame flicker which survived #GKEHPF, and make temporal capture observe the shadow decision directly rather than infer it from final colour.

Evidence:

  • --shadow-only 1 uses the otherwise-unused fog-colour W lane to replace block material output with direct-shadow. It preserves the exact runtime shadow map, projection, sampler, kernel, and sun sequence while removing albedo, ambient colour, fog, and most final 8-bit colour quantization as confounds.
  • The diagnostic made the missed phenomenon unmistakable: supposedly lit horizontal faces were covered by fine alternating light/dark stripes. As the sun rotated, this self-shadow acne crawled across the whole receiver. The original final-colour mean hid a spatially coherent alternating-sign pattern inside a small image-wide scalar; raw shadow visibility showed about 19 to 20 percent of block-surface pixels changing per display-equivalent frame.
  • A fixed five-texel radius falsified the single-nearest-blocker hypothesis as the principal cause: both first and second temporal derivative measures grew. The adaptive two-to-six-texel footprint remains.
  • A pure 120-frame transform probe found the light-space center's one-texel stabilization steps at frames 31 and 78, while the strongest raw-shadow first-derivative events occurred at frames 70, 89, and 80. Texel snapping can still make an occasional grid event, but it does not explain the field-wide constant pattern in this reproduction.
  • Every old PCF tap compared its displaced depth texel to the center fragment's receiver depth. On a face oblique to the light, the receiver's own depth changes substantially across a two-to-six-texel kernel; the test therefore misclassified the receiver plane itself as a sequence of occluders. A larger scalar slope bias can hide this only by increasing peter-panning and detached contacts.
  • The block fragment shader now derives an analytical receiver-plane depth gradient from the face normal, normalized light-space right/up/forward rows, and the orthographic world-span/depth-span ratio. The denominator is bounded near grazing incidence, where direct sun is negligible. Each of the 17 comparison taps adjusts its reference depth by the dot product of that gradient and its UV offset before applying the small ordinary bias.
  • In otherwise identical 120-frame, fixed-camera, 60-Hz-equivalent sunset captures, raw-shadow mean first derivative fell from 0.00188675 to 0.00073287 (61.2 percent), and peak changed area fell from 0.20165 to 0.07720 (61.7 percent). Mean second derivative fell from 0.00279323 to 0.00096447 (65.5 percent). The diagnostic plates change from field-wide stripes to activity concentrated at actual cast-shadow boundaries.
  • In the ordinary rendered first 59 comparisons, mean first derivative fell from 0.00009798 to 0.00001835 (81.3 percent), peak changed area from 0.01829 to 0.00565 (69.1 percent), and mean second derivative from 0.00023217 to 0.00005004 (78.4 percent). The analytic yard retains attached pillar contact and the same coherent penumbra.

Done when: a capture mode exposes shadow visibility without material and sky confounds; the broad crawling receiver pattern is visible before and absent after an explained geometric correction; normal and shadow-only derivatives both improve substantially; contact remains attached; and tests, SPIR-V validation, runtime capture, and smoke pass.

DONE The shimmer that only noon could show #0604PY

Intent: #X9Q2YS removed the field-wide receiver-plane acne and left cast shadows shimmering anyway. Play reported the residue as worst near noon, which is a strong clue: a defect with a preferred hour has a mechanism that depends on the sun's elevation.

Evidence:

  • A frozen-sun, frozen-camera control over 20 frames produced a first derivative of exactly zero, so the capture is bit-deterministic and every measured change is sun motion rather than instrument noise. Worth doing first; it costs one run and it retires a whole class of explanation.
  • Presentation now hands the --shadow-only diagnostic through ungraded. Exposure, the filmic curve, the lens chain, and the vignette had all arrived between #X9Q2YS and this work, and every one of them distorts the quantity the diagnostic exists to measure. An instrument that shares a pipeline with the picture needs saying so.
  • At pinned noon the diagnostic plate showed fine vertical striping across wall faces, not activity at cast-shadow boundaries. Those are faces the sun barely reaches, where the receiver plane runs almost parallel to the light: every filter tap lands an ill-conditioned distance away and the answer is noise. #X9Q2YS's bounded denominator keeps that finite without making it mean anything. Near noon it is every vertical face at once, which is the hour dependence.
  • A surface turned away from the light has no shadow decision to make, so the material now fades the decision toward lit as the cosine falls, handing over exactly where the denominator bound begins. It costs nothing visually because the same cosine multiplies the direct term to zero. Raw shadow visibility at noon: mean first derivative 0.00117895 to 0.00052242, changed area 0.08791 to 0.03441.
  • The second cause was the light basis, and it is the one #X9Q2YS could not have found at dusk. Texel snapping stabilizes the projection against translation and can do nothing about rotation, so the roll about the light axis is a free choice worth making well. World up is badly conditioned for it: the sun's angle to world up changes all day, the transverse component collapses toward the orbit tilt as the sun climbs, and the roll rate therefore peaks at noon --- spinning the whole texel grid under the world exactly when the sun is highest. The sun's own axis of revolution (sky-sun-orbit-axis) keeps a constant angle to the sun at every hour. Noon fell further, to 0.00020979 and 0.02110: 82.2 and 76.0 percent below the start.
  • The trade is real and was measured rather than assumed. At dusk the new basis is slightly worse, 0.00050110 to 0.00061021, because world up happens to be near-stationary when the sun is low. A uniformly conditioned reference costs a little at the best hour and saves a great deal at the worst one; the worst hour is what play notices.
  • In the ordinary rendered image at noon, mean first derivative fell from 0.00016889 to 0.00006079 (64.0 percent) and changed area from 0.02105 to 0.00812 (61.4 percent). The analytic yard keeps attached pillar contact and a coherent penumbra.
  • The specular lobe added with #IC14P3 was missing its cosine, which had given the noise a path into the visible image on exactly the faces where it was worst.

The lesson worth keeping is about evidence rather than shadows: #X9Q2YS settled on one explanation using captures pinned at 0.70, and its measurements were honest. The hour it chose was the hour where the second cause is weakest. A defect that play reports as time-dependent should be measured across the day before it is explained.

Done when: a frozen control proves the instrument; the diagnostic is ungraded again; both causes are separately measured at the hour that shows them; the day-long trade is stated rather than hidden; and contact remains attached.

deftest shader-source-is-a-typed-clos-graph tests.lisp:367
deftestshader-source-is-a-typed-clos-graph
let*
sun-direction
binding-named'sun-directionspecification
sun-visibility
binding-named'sun-visibilityspecification
direct-shadow
binding-named'direct-shadowspecification
reflected
binding-named'reflectedspecification
radiance
binding-named'radiancespecification
fogged
binding-named'foggedspecification
ok
typepspecification'shader:shader-specification
ok
eq
shader:shader-specification-stagespecification
:fragment
ok
=
length
shader:shader-specification-inputsspecification
9
ok
=
length
shader:shader-specification-resourcesspecification
7
ok
eq:world-direction
ok
shader:shader-type=
shader:shader-expression-type
:vec3
ok
equal'
"smoothstep"
"quantity"0.9"quantity""sky-light-level""unit""one"
"quantity"1.0"quantity""sky-light-level""unit""one"
"sky-input"

The map's answer is taken only where the surface faces the light; a surface turned away is lit by nothing, so it is also shadowed by nothing. #0604PY

ok
equal'
"mix"1.0"shadow-sample""shadow-in-bounds"
ok
equal'
"mix"1.0"sampled-shadow""shadow-relevance"
ok
equal'
"interpret"
"*""albedo"
"+""sky-light""sun-light""local-light"
"quantity""linear-rgb""unit""one"
ok
equal'
"+""reflected""specular"
"interpret"
"*""albedo""emission-input"
"quantity""linear-rgb""unit""one"
ok
equal'
"mix""radiance""fog-color""fog-amount"
dolist
binding
listsky-lightreflectedradiancefogged
ok
eq:absolute
ok
>
length
shader:shader-specification-bindingsspecification
defun shadow-frame-rows render.lisp:218
defunshadow-frame-rows
camerasky&optionalanchor

Pack a texel-stable orthographic light-space transform as four vec4 rows.

Returns the rows and the anchor to hand back next frame. The anchor is the world point the light-space texel lattice is built around: it follows the camera in whole texels of the current light basis, so camera translation never moves the lattice by a fraction of a texel, and the sun's rotation turns the lattice about the camera rather than about the world origin. Without an anchor the camera position itself starts one.

let*
center
forward
vec3-scale
vec3-normalize
sky-frame-parameters-sun-directionsky
-1.0

Texel snapping stabilizes the map against translation but can do nothing about rotation, so the free choice of roll about the light axis is worth making well. World up is a continuous reference -- the tilted orbit never reaches the Y pole -- but it is a badly conditioned one: the sun's angle to it changes all day, the transverse component collapses toward the orbit tilt as the sun climbs, and the roll rate consequently peaks at noon, spinning the whole texel grid under the world exactly when the sun is highest. The sun's own axis of revolution keeps a constant angle to the sun at every hour, so the basis it induces simply turns with the day. #0604PY measures both the gain at noon and what it costs at dusk.

right
vec3-normalize
vec3-crossreferenceforward
up
vec3-crossforwardright
world-units-per-texel

Rotation still slides the lattice under the world, by the angle times the distance from the pivot. Snapping the camera to a lattice through the world origin put that pivot at the origin, so a player standing a few hundred units out watched every shadow edge vibrate a texel many times a second however slow the day. The pivot is instead a persistent anchor that walks toward the camera in whole texels of this frame's basis: the lattice never shifts under translation, and it rotates about a point within a texel of the eye. The anchor is kept in double precision so that the walk does not itself wobble the lattice. #QWTQ6R

anchor
let*
start
oranchorcenter
delta
make-vec3
-
vec3-xcenter
vec3-xstart
-
vec3-ycenter
vec3-ystart
-
vec3-zcenter
vec3-zstart
along-right
*
round
/
vec3-dotdeltaright
world-units-per-texel
world-units-per-texel
along-up
*
round
/
vec3-dotdeltaup
world-units-per-texel
world-units-per-texel
along-forward
vec3-dotdeltaforward
flet
walk
coerce
+
*along-right
*along-forward
'double-float
make-vec3
walk:x
walk:y
walk:z
center-right
vec3-dotanchorright
center-up
vec3-dotanchorup
flet
lane
axisscaleoffset
list
coerceoffset'single-float
values
append
laneright
/extent
-
/center-rightextent
laneup
/extent
-
/center-upextent
laneforward
/
*2.0depth-radius
-0.5
/
vec3-dotanchorforward
*2.0depth-radius
'
0.00.00.01.0
anchor

DONE The shadow lattice turned about the world origin #QWTQ6R

Intent: play still reported every cast shadow flickering and jittering whenever the clock ran, at any day length, and calm only once the clock stopped. #0604PY had already chosen the best roll for the light basis; the residue was too fast and too uniform to be roll.

Evidence:

  • The live capture at day fraction 0.42, stepping the sun by one frame of a ten-minute day (0.000028) with the camera fixed and the shadow-only diagnostic on, showed a penumbra pixel of the big tree's shadow run 24, 23, 18, 10, 0, 0, 0, 12, 22 over nine frames: the sun moved one way the whole time and the edge went back and forth. Distant wall-foot contact lines lit up in every frame difference.
  • The mechanism is the snap, not the roll. SHADOW-FRAME-ROWS rounded the camera's light-space position to whole texels, which makes the lattice of texel boundaries the set of world points with p . right in t Z: a family of planes through the world origin, rigidly attached to the rotating basis. A rotation by d turns that family about the origin, so at a point p the lattice slides by |p_xy| d. The camera stood at x = 127, y = 7, about 144 units from the origin axis: 2.5 cm, 0.4 texel, every frame of a ten-minute day -- a six-centimetre sawtooth at 24 Hz under every shadow edge, and four times a second even at an hour per day. The amplitude is one texel whatever the day length; only the rate scales, which is why slowing the clock never cured it.
  • The pivot is now a persistent anchor (luvcraft-session-shadow-anchor) that walks toward the camera each frame by whole texels of that frame's right and up, and freely along forward. Whole-texel steps cannot move the lattice, so translation stays exactly stable, and the sun's rotation turns the lattice about a point within a texel of the eye. The anchor is kept in double precision so the walk does not itself wobble anything.
  • Same capture afterwards: the same penumbra pixel runs 6, 7, 8, 9, 9, 10, 11, 11, 12, 13 -- monotone, one level a frame, the shadow simply moving. The distant contact lines are quiet except for the occasional single texel step that a rotating lattice at thirty metres must still take.

What remains is the irreducible part: the lattice still turns about the eye, so a shadow edge r units away sees a one-texel step every t / (r w) seconds -- at ten metres and a ten-minute day, one every 0.6 s; at an hour per day, one every 3.6 s. Below that lies the quantization every rasterized shadow map has, a silhouette that can only move in texels. The candidates from here are finer texels near the eye (a near cascade), a wider minimum filter so one texel is a smaller fraction of the penumbra, or a projection onto a fixed ground-plane lattice, which removes the rotation altogether for receivers at the reference height at the cost of redoing the receiver-plane gradient for a sheared basis.

Done when: the frozen-camera, stepping-sun capture shows the penumbra moving monotonically; SHADOW-LATTICE-TURNS-ABOUT-THE-CAMERA-NOT-THE-ORIGIN holds a probe three units from an eye seven hundred units out to a hundredth of a texel across one frame of sun; and walking still leaves the footprint exactly still under sub-texel translation.

defun shadow-frame-rows render.lisp:218
defunshadow-frame-rows
camerasky&optionalanchor

Pack a texel-stable orthographic light-space transform as four vec4 rows.

Returns the rows and the anchor to hand back next frame. The anchor is the world point the light-space texel lattice is built around: it follows the camera in whole texels of the current light basis, so camera translation never moves the lattice by a fraction of a texel, and the sun's rotation turns the lattice about the camera rather than about the world origin. Without an anchor the camera position itself starts one.

let*
center
forward
vec3-scale
vec3-normalize
sky-frame-parameters-sun-directionsky
-1.0

Texel snapping stabilizes the map against translation but can do nothing about rotation, so the free choice of roll about the light axis is worth making well. World up is a continuous reference -- the tilted orbit never reaches the Y pole -- but it is a badly conditioned one: the sun's angle to it changes all day, the transverse component collapses toward the orbit tilt as the sun climbs, and the roll rate consequently peaks at noon, spinning the whole texel grid under the world exactly when the sun is highest. The sun's own axis of revolution keeps a constant angle to the sun at every hour, so the basis it induces simply turns with the day. #0604PY measures both the gain at noon and what it costs at dusk.

right
vec3-normalize
vec3-crossreferenceforward
up
vec3-crossforwardright
world-units-per-texel

Rotation still slides the lattice under the world, by the angle times the distance from the pivot. Snapping the camera to a lattice through the world origin put that pivot at the origin, so a player standing a few hundred units out watched every shadow edge vibrate a texel many times a second however slow the day. The pivot is instead a persistent anchor that walks toward the camera in whole texels of this frame's basis: the lattice never shifts under translation, and it rotates about a point within a texel of the eye. The anchor is kept in double precision so that the walk does not itself wobble the lattice. #QWTQ6R

anchor
let*
start
oranchorcenter
delta
make-vec3
-
vec3-xcenter
vec3-xstart
-
vec3-ycenter
vec3-ystart
-
vec3-zcenter
vec3-zstart
along-right
*
round
/
vec3-dotdeltaright
world-units-per-texel
world-units-per-texel
along-up
*
round
/
vec3-dotdeltaup
world-units-per-texel
world-units-per-texel
along-forward
vec3-dotdeltaforward
flet
walk
coerce
+
*along-right
*along-forward
'double-float
make-vec3
walk:x
walk:y
walk:z
center-right
vec3-dotanchorright
center-up
vec3-dotanchorup
flet
lane
axisscaleoffset
list
coerceoffset'single-float
values
append
laneright
/extent
-
/center-rightextent
laneup
/extent
-
/center-upextent
laneforward
/
*2.0depth-radius
-0.5
/
vec3-dotanchorforward
*2.0depth-radius
'
0.00.00.01.0
anchor

DONE HDR crystal bloom #WLHDRB

Intent: give emissive materials headroom and bloom without abusing alpha or the current LDR scene target.

Observed: the scene attachment is :rgba16-float, presentation applies exposure and a fitted ACES curve, and a quarter-resolution bright/blur/sweep chain adds bloom and shafts in linear light before that curve (#IC14P3). The crystal-at-night capture in #IC14P3 shows the emissive blocks blooming into a dark sky while the terminal wall beside them stays readable and the ground they light stays in the toe of the curve rather than crushing. Scene alpha was not commandeered; the relief channel #CHUKWD uses is normal-atlas alpha, which is a different surface and is declared as what it holds.

The lens attachments are session-owned and retire with the rest of the session's GPU resources, but resize is not handled --- and was not handled for the scene or depth attachments before this either, so the gap is pre-existing rather than new. #YMAQQD carries it.

Exposure, bloom gain, threshold, shaft gain and decay, and vignette are live specials rather than sky-profile keyframes. A profile lane is the better home once a keyframed value is actually wanted for time of day; nothing so far has wanted one.

DONE Materials and sky taken toward physical shading #36LYCR

Intent: the world read flat because every face of a material was the same card, the specular was a fitted power, everything that was not the sun was one grey, and the sky's lower half was a wall of fog. Take each of those to a physical argument instead: #3AVEKC, #2T8CCH, #8X33G2, #RSGLTL.

Evidence: pinned captures at day fractions 0.28, 0.50, 0.72, 0.80, and 0.93, from a ground stance and from a vista, against the same captures before the change. make test green throughout, including the strict parinfer pass, SPIR-V validation of every stage, and the material and sky shape tests updated to what the stages now say. The live canvas held sixty frames a second at 2688x1680 with the deck shadow's fractal noise in the block fragment stage.

What is left is art direction rather than architecture, and all of it is on knobs: surface-detail, surface-roughness, specular-gain, ambient-bounce, cloud-coverage, cloud-altitude, cloud-shadow-depth, sky-scatter-gain, star-brightness, moon-radiance, and the grade's saturation, contrast, and aberration.

TODO Verify resized presentation in a live capture #YMAQQD

Intent: the scene colour, depth, presentation, and lens-chain attachments are all created once from the canvas extent at session start. A resized window therefore presents a stale-sized image. The bug predates the lens chain, but the chain multiplies the number of textures which have to agree.

Evidence: luvcraft-renderer now owns one frame-attachments cohort containing the scene colour, presentation, depth, lens, crosshair, and cursor resources. Resize builds and initializes a complete candidate first, discards frame-state bindings keyed by the old attachments, publishes the cohort in one slot write, then retires the old resources. Construction failure leaves the old cohort installed; retirement failure leaves the new cohort installed and retains the failed handle for teardown retry. Synthetic renderer tests observe only whole attachment plists through the session readers and verify live-session migration into the same owner.

The ownership and publication conditions now hold in tests. The remaining evidence is deliberately visual: resize a live game, capture it, and compare it with a frame started at that extent. Until that confirms both presentation size and the absence of stale attachments, this mark stays open.

Decisions now made; questions left to evidence #SKOPEN

Made here:

Questions for probes rather than prior debate: