Skip to content

Shadow acne at certain hours: ShadowMap.SLOPE is constant but the day cycle moves the sun (residual of #9) #39

Description

@abner-augusto

Filed separately from #9 because that thread is about the symptom and this is a specific
remaining cause with a patch — happy to have it folded in there if you'd rather.

#9 reports diagonal lines across sprites; you replied that the latest should be largely
resolved, and the reporter came back with "much better, but still there." I think this is
the still there. The 1.2.0 ShadowMap.snug work fixed one cause of diagonal moire (a
caster stored snugged but read un-snugged). I verified that path is correct in 1.3.0 — see
"Not a snug mismatch" below — so what follows is a second, independent cause of the same
artefact.

Mod: DRAMATIC_SHAPE 1.3.0 · Host: gen1recomp v0.1.45 (Yellow), win64 · Mode: voxel rung 50, outdoors
Setting: DAYTIME = SYNC (the default) or CYCLE. Not reproducible on any of the four pins.

Includes a tested fix: ~29 lines of code, no shader change, no vertex format change.
Images are attached in a comment below.

Symptom

Diagonal bands crawling across overworld character sprites, worst while walking. Most
visible on large flat colour areas — an NPC's red cap, the player's cap and face — and
invisible on the black outline pixels, which is what makes it read as a moire rather than
as a shadow. The same thing happens on building walls at other hours.

Root cause

ShadowMap.SLOPE = 3.1 is a constant slope-scaled bias, but the depth ramp it has to
cover depends on the angle between the receiving surface and the sun — and since the day
cycle landed, the sun moves. DayNight.applyRig overwrites ShadowMap.KX/KZ every frame
(DayNight.lua:396); SLOPE does not follow.

ShadowMap.lua:97-112 derives 3.1 from the worst terrain case under the old fixed noon
sun, and lists "a vertical wall, by comparison, manages 1.7, flat ground 0.7". Those
figures only hold at shear (-0.85, -0.55).

Sprite cards break first, because two of the assumptions behind that constant don't cover
them:

  1. Their plane never turns. Voxel3D.casterMatrix applies no yaw, so every character
    card is a vertical plane with normal +Z (south), always. (mirror is
    scale(-1,1,1) — it flips winding, not the normal.)
  2. They are always "lit". SpriteBillboards.buildCard writes VertexShade = 1 on all
    four vertices. The argument that justifies SLOPE — "anything steeper than that roof
    has its back to the sun and never reads the map at all" — rests on FACE_SHADE
    darkening away-facing geometry. A card has no FACE_SHADE, so it samples the map at
    every orientation.

The depth ramp for a plane of normal n under light direction f is
sqrt(1-(n·f)²)/|n·f|, which diverges as the light grazes the plane. For the card
n·f = kz/|(kx,-1,kz)|, so it blows up as the sun's bearing approaches due east or due
west
, i.e. as kz → 0.

DayNight.bodyAt sweeps the bearing TH_RISE -70° → TH_NOON 32.9° → TH_SET 250°, crossing
both:

dial t SYNC clock bearing elev shear card n·f ramp
204 s ~10:05 0° (due east) 39.0° (-1.23, 0.03) +0.019 ≈52
503 s ~16:04 180° (due west) 21.7° (2.00, -0.08) −0.037 ≈27

Both sit well above FADE_DEG = 12, so strengthAt is 1.0 — the shadows are at full
weight exactly when the bias is at its worst.

Reproducing ShadowMap.fit() for a 160×144 view at 50° pitch and comparing the slack
supplied against the slack the card's own plane needs:

 clock | texel px | slack supplied | slack card needs | short by
  9.0h |     0.38 |           1.69 |             1.46 |   0.9x
 10.0h |     0.38 |           1.68 |            14.65 |   8.7x  <-- ACNE
 11.0h |     0.42 |           1.80 |             1.96 |   1.1x  <-- ACNE
 12.0h |     0.44 |           1.86 |             1.24 |   0.7x       (noon: fine)
 16.0h |     0.35 |           1.59 |             7.24 |   4.5x  <-- ACNE

(need = BIAS + ramp*0.707*texel, matching the half-texel-per-axis convention
voxel_acne_probe.lua already uses for the 2×2 filter.)

The flicker is the map being re-fitted: shadowSignature includes every posed entity and
quarter-pixel camera position, so walking rebuilds the map each frame and the card slides
across the light-space texel grid.

Not a snug mismatch. ShadowMap.snug's docstring warns that a caster stored snugged but
read un-snugged produces this exact artefact. Both the free-roam path (VoxelScene.lua:316
lit / :582 cast) and the battle path (BattleScene.lua:432 lit / :254 cast) hand it to
both sides correctly in 1.3.0. Ruled out — this is a different cause.

The trigger is a surface that is JUST BARELY LIT

|n·f| → 0 is not on its own enough. What matters is whether the surface stores itself in
the map
, and only a front-facing one does:

  • n·f < 0 — front-facing, however slightly. The surface is the first thing the light
    reaches, so the depth it compares against is its own, sampled a texel away. As the angle
    grazes, that self-comparison goes unboundedly marginal. This is the bug.
  • n·f ≥ 0 — back-facing or exactly parallel. The ray reaches the opposite wall, the
    roof or the ground first (and a zero-thickness card's light-space footprint collapses to a
    line), so the surface never records itself. It compares against unrelated geometry at a
    decisively different depth and comes out cleanly whichever way it resolves — lit or
    shadowed, but never moired.

The clean test is the Poké Center's front wall shot at all four DAYTIME pins from one
fixed viewpoint
— same wall, same camera, only the clock moving (frames aligned to the
pixel by phase correlation; the window drifted 6–9 px between shots):

pin t sun bearing n·f (south wall) predicted observed
DAY 300 32.9° −0.3865 solidly lit clean clean
NIGHT 379 due south −0.7550 solidly lit clean clean
DAWN 204 due east +0.0003 parallel/behind clean clean
DUSK 503 due west −0.0030 just barely lit moire bands

(t values are temporarily repointed pins, used only to reach those bearings on demand.)

One panel of four is banded, and it is the predicted one. The decisive pair is the last
two: the same wall, at +0.0003 and −0.0030 — the same near-tangent geometry,
magnitudes both ≈ 0, opposite sign, opposite outcome. That is what rules out |n·f| alone
as the predictor. The two solidly-lit pins are the controls, and both are clean.

Worth noting because it changes where to look: at DAWN the south wall is largely lit,
with a clean-edged cast shadow across part of it. "Edge-on to the sun" does not mean "dark",
and the surfaces at risk are the ones that look normally lit.

Why the probe and the pins both miss it

voxel_acne_probe.lua sweeps zoom, grid and flat-bias variants but never the clock, so
it always evaluates at whatever rig is current — the default noon — where the south-facing
plane ramps 2.39 and passes. Its FACES list already contains south +Z, and it already
gates on if nf < 0, which per the section above is exactly the right discriminator. The
check is right; it just never sees the bearing that breaks it.

And every pin is safe: DayNight.T puts DAWN at bearing -70°, DAY at 32.9°, DUSK at 250°
(where strengthAt is 0 anyway), and NIGHT on the moon arc, whose bearing stays in
[-160°, -20°] and never approaches 0°/180°. Only SYNC — the default — and CYCLE pass
through the failing bearings
, which is likely why it reads as intermittent. To reproduce
on demand, temporarily repoint a pin at t = 503.

Fix

Not a bigger number — there isn't one that works, because the ramp is unbounded. Move the
lookup instead: offset it off its own plane, along the normal, toward the light.

Both halves help at once. Our own depth falls by |n·f|·s; the stored depth at the shifted
sample rises by s·(1-(n·f)²)/|n·f|. They sum to exactly s/|n·f| of margin, against the
s_tap·sqrt(1-(n·f)²)/|n·f| a tap can disagree by. The |n·f| cancels — an offset of
one tap radius suffices at every angle, grazing included. And it stays bounded where slack
cannot: the lookup moves NUDGE texels in world space, measured at 0.34–0.45 world px
across the dial, so a shadow genuinely falling on a card still lands where it fell.

New in ShadowMap.lua, beside snug (which it composes with):

ShadowMap.NUDGE = 1.0   -- texels; the 2x2 filter reaches 0.707 out on the diagonal

function ShadowMap.nudge(model, nx, ny, nz)
  local f = sunDir()
  local s = ShadowMap.NUDGE * (ShadowMap.texel or 0)
  -- f is the direction the light TRAVELS, so a normal with a positive dot
  -- points away from the sun and the offset has to go the other way
  if nx * f[1] + ny * f[2] + nz * f[3] > 0 then s = -s end
  return Mat4.mul(Mat4.translate(nx * s, ny * s, nz * s), model or IDENTITY)
end

fit() gains one line exposing ShadowMap.texel = math.max(w, h) / res (slack already
computed it inline). Call sites wrap the existing snug — the card's plane is +Z in the
world, and in the arena it's the yaw carried onto (0,0,1):

-- VoxelScene.drawEntity, and both eachFigure draws
ShadowMap.nudge(ShadowMap.snug(Voxel3D.casterMatrix(px, py, y, mirror)), 0, 0, 1)

-- BattleScene mon cards, via a monNormal(x, z) helper = {sin(yaw), 0, cos(yaw)}
ShadowMap.nudge(ShadowMap.snug(card.model), card.normal[1], card.normal[2], card.normal[3])

No shader change, no vertex format change, nothing touched outside the card paths — nudge
composes into the sunModel matrix Voxel3D.draw already takes, so the offset rides a
transform that was going to be sent anyway. ShadowMap.NUDGE = 0 returns the model
untouched, which is the pre-fix behaviour — that is the switch this was A/B'd on.

Result: swept over every vertical card orientation × the whole dial (108,000 samples),
11.2% land short of the slack they need; with the nudge that goes to 0%, worst-case
margin -0.315 → +0.109. Confirmed in game: banding gone from the cap and face, with
nothing else in the frame changed.

Terrain has the same problem, and this fix does not address it

The Poké Center's front wall faces south, so its normal is (0,0,1)the same plane as a
character card
, with the same ramp of 27.1 at t = 503 against the same 3.1 budget. It
bands at t = 503 and is clean at noon, per the four-pin table above. Unchanged before and
after the fix, as expected — nothing in the patch touches terrain.

By the arithmetic it should not be confined to south-facing walls: at t = 379 the bearing
is due south, which tips east/west walls barely into the light, and every wall reaches
that angle at some hour. That case is predicted but not demonstrated — the vantage I had
put the west wall about 7 px wide, too narrow to judge.

The underlying reason is worth stating on its own: FACE_SHADE is a fixed noon bake and
deliberately does not follow the clock
(DayNight.lua:31-34). So a south wall stays at
0.90 brightness and keeps reading the shadow map even once the sun has swung due west and is
raking along it. The "steep faces are dark anyway" premise behind SLOPE = 3.1 quietly stops
holding the moment the day cycle is enabled.

Fixing terrain needs the receiver's normal per fragment, which the card path gets for free
and terrain does not. VertexShade can't stand in — ChunkMesher multiplies AO into it per
corner (aoShades/groundShades/sideShades) and special-cases d == 5, so it doesn't
decode back to a direction. That leaves a real normal in Voxel3D.FORMAT, or geometric
normals from dFdx/dFdy with the usual no-derivatives fallback — an architecture call, so
I've left it rather than guess.

Two loose observations, offered as-is rather than diagnosed

  • At t = 379, the shadow the Poké Center throws on the ground beside it has a coarsely
    stair-stepped edge with a distinct notch. Its interior is uniform, so it is not the moire
    above. May be nothing but the map's texel resolution at that frustum size — reach grows
    with the shear, so a low sun widens the frustum and coarsens every texel in it.
  • In the stock configuration, minor acne is visible on recessed door frames even at the
    DAY pin, where the flat faces all pass comfortably. Creases may run tighter against the
    constant than the flat-face analysis behind SLOPE = 3.1 accounts for, independent of the
    day cycle.

Probe coverage

Adding a clock sweep to voxel_acne_probe.lua (walk t across CYCLE, re-run applyRig,
report per face) would have caught this and would guard the fix.


Filed by a player, not a developer. Analysis is static reading of the 1.3.0 source plus a
numeric reproduction of DayNight.bodyAt / shearAt / ShadowMap.fit; the fix was built
and A/B tested in a live build against a temporarily repointed DUSK pin, with NUDGE as the
only variable between runs. Great mod — thanks for it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions