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:
- 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.)
- 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.
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.snugwork fixed one cause of diagonal moire (acaster 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.1is a constant slope-scaled bias, but the depth ramp it has tocover depends on the angle between the receiving surface and the sun — and since the day
cycle landed, the sun moves.
DayNight.applyRigoverwritesShadowMap.KX/KZevery frame(
DayNight.lua:396);SLOPEdoes not follow.ShadowMap.lua:97-112derives 3.1 from the worst terrain case under the old fixed noonsun, 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:
Voxel3D.casterMatrixapplies no yaw, so every charactercard is a vertical plane with normal +Z (south), always. (
mirrorisscale(-1,1,1)— it flips winding, not the normal.)SpriteBillboards.buildCardwritesVertexShade = 1on allfour vertices. The argument that justifies
SLOPE— "anything steeper than that roofhas its back to the sun and never reads the map at all" — rests on
FACE_SHADEdarkening away-facing geometry. A card has no
FACE_SHADE, so it samples the map atevery orientation.
The depth ramp for a plane of normal
nunder light directionfissqrt(1-(n·f)²)/|n·f|, which diverges as the light grazes the plane. For the cardn·f = kz/|(kx,-1,kz)|, so it blows up as the sun's bearing approaches due east or duewest, i.e. as
kz → 0.DayNight.bodyAtsweeps the bearingTH_RISE -70° → TH_NOON 32.9° → TH_SET 250°, crossingboth:
tn·fBoth sit well above
FADE_DEG = 12, sostrengthAtis 1.0 — the shadows are at fullweight exactly when the bias is at its worst.
Reproducing
ShadowMap.fit()for a 160×144 view at 50° pitch and comparing the slacksupplied against the slack the card's own plane needs:
(
need = BIAS + ramp*0.707*texel, matching the half-texel-per-axis conventionvoxel_acne_probe.luaalready uses for the 2×2 filter.)The flicker is the map being re-fitted:
shadowSignatureincludes every posed entity andquarter-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 butread un-snugged produces this exact artefact. Both the free-roam path (
VoxelScene.lua:316lit /
:582cast) and the battle path (BattleScene.lua:432lit /:254cast) hand it toboth 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| → 0is not on its own enough. What matters is whether the surface stores itself inthe map, and only a front-facing one does:
n·f < 0— front-facing, however slightly. The surface is the first thing the lightreaches, 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, theroof 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):
tn·f(south wall)(
tvalues 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.0003and−0.0030— the same near-tangent geometry,magnitudes both ≈ 0, opposite sign, opposite outcome. That is what rules out
|n·f|aloneas 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.luasweeps zoom, grid and flat-bias variants but never the clock, soit always evaluates at whatever rig is current — the default noon — where the south-facing
plane ramps 2.39 and passes. Its
FACESlist already containssouth +Z, and it alreadygates on
if nf < 0, which per the section above is exactly the right discriminator. Thecheck is right; it just never sees the bearing that breaks it.
And every pin is safe:
DayNight.Tputs DAWN at bearing -70°, DAY at 32.9°, DUSK at 250°(where
strengthAtis 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 shiftedsample rises by
s·(1-(n·f)²)/|n·f|. They sum to exactlys/|n·f|of margin, against thes_tap·sqrt(1-(n·f)²)/|n·f|a tap can disagree by. The|n·f|cancels — an offset ofone tap radius suffices at every angle, grazing included. And it stays bounded where slack
cannot: the lookup moves
NUDGEtexels in world space, measured at 0.34–0.45 world pxacross the dial, so a shadow genuinely falling on a card still lands where it fell.
New in
ShadowMap.lua, besidesnug(which it composes with):fit()gains one line exposingShadowMap.texel = math.max(w, h) / res(slackalreadycomputed it inline). Call sites wrap the existing snug — the card's plane is
+Zin theworld, and in the arena it's the yaw carried onto
(0,0,1):No shader change, no vertex format change, nothing touched outside the card paths —
nudgecomposes into the
sunModelmatrixVoxel3D.drawalready takes, so the offset rides atransform that was going to be sent anyway.
ShadowMap.NUDGE = 0returns the modeluntouched, 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, withnothing 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 acharacter card, with the same ramp of 27.1 at
t = 503against the same 3.1 budget. Itbands at
t = 503and is clean at noon, per the four-pin table above. Unchanged before andafter the fix, as expected — nothing in the patch touches terrain.
By the arithmetic it should not be confined to south-facing walls: at
t = 379the bearingis 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_SHADEis a fixed noon bake anddeliberately does not follow the clock (
DayNight.lua:31-34). So a south wall stays at0.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.1quietly stopsholding 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.
VertexShadecan't stand in —ChunkMeshermultiplies AO into it percorner (
aoShades/groundShades/sideShades) and special-casesd == 5, so it doesn'tdecode back to a direction. That leaves a real normal in
Voxel3D.FORMAT, or geometricnormals from
dFdx/dFdywith the usual no-derivatives fallback — an architecture call, soI've left it rather than guess.
Two loose observations, offered as-is rather than diagnosed
t = 379, the shadow the Poké Center throws on the ground beside it has a coarselystair-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 —
reachgrowswith the shear, so a low sun widens the frustum and coarsens every texel in it.
DAY pin, where the flat faces all pass comfortably. Creases may run tighter against the
constant than the flat-face analysis behind
SLOPE = 3.1accounts for, independent of theday cycle.
Probe coverage
Adding a clock sweep to
voxel_acne_probe.lua(walktacrossCYCLE, re-runapplyRig,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 builtand A/B tested in a live build against a temporarily repointed DUSK pin, with
NUDGEas theonly variable between runs. Great mod — thanks for it.