From 2fdd47c7b6d0b2f1ca8310a7453731792374456e Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 12:59:04 -0300 Subject: [PATCH 1/9] add: GRASS_WIND --- lib/BattleScene.lua | 2 + lib/ChunkMesher.lua | 9 +++- lib/Structures.lua | 17 ++++++- lib/Voxel3D.lua | 114 ++++++++++++++++++++++++++++++++++++++++++++ lib/VoxelScene.lua | 3 ++ 5 files changed, 142 insertions(+), 3 deletions(-) diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 008d84b..0dcec37 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -654,11 +654,13 @@ function BattleScene.render(state, arena, textures, token) -- pull is also what keeps a tuft from z-fighting the floor it stands on local pull = VoxelScene.pull(math.max(pitch, 0.05)) if not discs then + Voxel3D.sway(true) Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull) for _, nb in ipairs(neighbors) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy), pull) end + Voxel3D.sway(false) local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05))) Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull, ShadowMap.snug(nil)) diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index eb2dbb9..84bc290 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -819,12 +819,17 @@ local function quadsMesh(quads) for i = 1, 4 do local c = q[i] local uv = q.uv and q.uv[i] or { q.u, q.v } - verts[#verts + 1] = { c[1], c[2], c[3], uv[1], uv[2], q.shade } + verts[#verts + 1] = { c[1], c[2], c[3], uv[1], uv[2], q.shade, + q.sway or 0 } end Voxel3D.pushQuad(indices, n) n = n + 1 end - return Voxel3D.newMesh(verts, indices) + -- these are the standing cutouts -- grass and flowers -- and the grass + -- rows bend in the wind, so they are built on the format that carries a + -- clump's phase. Flowers ride the same builder with a phase of zero and + -- are drawn outside the wind, so they cost one float and stand still. + return Voxel3D.newSwayMesh(verts, indices) end -- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the diff --git a/lib/Structures.lua b/lib/Structures.lua index 2c99c8d..d5f577e 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -3383,6 +3383,14 @@ end -- depth, so a clump read as two stubs rather than one tuft. local GRASS_THICK = 2 +-- The gust, as radians of wind phase per world pixel travelled. Together +-- these two set both the direction the wave rolls (diagonally, so it never +-- lines up with the tile grid and reads as a marching row) and how long it +-- is: about a hundred pixels from crest to crest, or a dozen tiles, so a +-- meadow on screen is caught mid-wave rather than leaning all one way. +local GRASS_WIND_X = 0.050 +local GRASS_WIND_Z = 0.031 + local function grassTemplate(map, data, tileId) local perRow = map.tileset.tilesPerRow or 16 local atlasW = map.tileset.imageWidth or 128 @@ -3480,13 +3488,20 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data) templates[tileId] = tpl end local wx, wz = tx * 8, ty * 8 + -- when this tuft's turn in the wind comes (Voxel3D.SWAY_FORMAT): + -- one number for the whole clump, so its blades bend together and + -- no quad is sheared. Read off the tile's own place on the map, so + -- the offset runs smoothly from cell to cell and a gust crosses a + -- meadow as a wave -- and so it is the SAME wave either side of a + -- map seam, where two chunks meet in world coordinates. + local phase = wx * GRASS_WIND_X + wz * GRASS_WIND_Z for _, q in ipairs(tpl) do quads[#quads + 1] = { { q[1][1] + wx, q[1][2], q[1][3] + wz }, { q[2][1] + wx, q[2][2], q[2][3] + wz }, { q[3][1] + wx, q[3][2], q[3][3] + wz }, { q[4][1] + wx, q[4][2], q[4][3] + wz }, - uv = q.uv, shade = q.shade, + uv = q.uv, shade = q.shade, sway = phase, } end end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index a15f265..b97d3e4 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -48,6 +48,25 @@ Voxel3D.FORMAT = { { "VertexShade", "float", 1 }, } +-- The same, plus the wind phase of the clump a vertex belongs to. Only the +-- meshes that actually bend carry it (the tall-grass rows), because the +-- phase has to be CONSTANT across a whole tuft: a quad in this world is up +-- to eight pixels wide, and a phase computed per vertex from its own +-- position leans the two ends of one blade by different amounts and shears +-- the blade instead of bending it. Baked per clump at build time, it is one +-- number for every vertex of that tuft and the quad stays a quad. +-- +-- The scene shader declares the attribute unconditionally, so the meshes +-- built on the narrower FORMAT above simply never supply it -- and never +-- read it either, since it is only touched while the wind is on and the +-- wind is only on around these meshes. +Voxel3D.SWAY_FORMAT = { + { "VertexPosition", "float", 3 }, + { "VertexTexCoord", "float", 2 }, + { "VertexShade", "float", 1 }, + { "VertexSway", "float", 1 }, +} + -- Face shading by direction id: top faces stay -- full brightness, sides step down so an extruded block reads as solid -- instead of a flat sticker, and the faces turned away from the sun are @@ -69,6 +88,24 @@ Voxel3D.FACE_SHADE = { [6] = 0.68, -- -Z north (away) } +-- ---- wind ---- +-- +-- How far a blade's TIP leans, in world pixels. One, and it has to be a +-- whole number: the vertex shader rounds the lean to the pixel grid the +-- rest of this world is drawn on, so anything under half a pixel would +-- never move at all and anything over one would tear a tuft off its root. +Voxel3D.SWAY_PIXELS = 1 + +-- The beat the lean SNAPS on, in steps per second, pinned to the engine's +-- own 60Hz tile clock the way the water's crests are. Below the water's +-- twelve on purpose: a blade has three states (left, still, right), so a +-- faster beat reads as a flicker rather than as wind. +Voxel3D.SWAY_FPS = 8 + +-- Seconds for the wind to come round again. Long enough that a meadow is +-- never caught doing the same thing twice in a glance. +Voxel3D.SWAY_PERIOD = 2.5 + local SHADER = [[ varying float vShade; varying vec3 vSun; // this fragment's place in the sun's view @@ -87,7 +124,9 @@ local SHADER = [[ uniform vec3 eye; uniform float pull; uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off + uniform vec2 sway; // x = pixels of lean, y = phase; 0 = off attribute float VertexShade; + attribute float VertexSway; // this clump's wind phase (see SWAY_FORMAT) vec4 position(mat4 transform_projection, vec4 vertex_position) { vShade = VertexShade; #ifdef VOXEL_GRID @@ -108,6 +147,30 @@ local SHADER = [[ // answered. (The pull below is excluded for the same reason: it is a // depth trick aimed at the camera's own buffer.) vSun = (sunVP * (sunModel * vertex_position)).xyz; + // Wind, on whatever pass asked for it (see Voxel3D.sway -- the tall + // grass rows). MODEL space decides how far a vertex leans: a tuft is + // built eight units tall from its own root, so y/8 pins the blade's + // foot still and gives the tip the whole amplitude, and a clump bends + // rather than sliding off its cell. WHEN it leans is the clump's own + // baked phase, so neighbouring tufts are caught at different moments + // and a gust rolls across a meadow instead of the field swinging as + // one board (see SWAY_FORMAT for why that is baked and not derived + // here from the vertex's own position). + // + // Quantised to whole world pixels, like the water's crests and for the + // same reason: this world has no half pixels anywhere else, and a + // blade leaning a third of one would only soften its own edge for a + // frame. So the lean SNAPS -- which is the Gen 1 idiom for movement. + // + // The sun lookup above is deliberately left at the still position. + // Grass casts no shadow (VoxelScene leaves it out of the sun pass), so + // this only decides where it RECEIVES one, and re-deriving the lean in + // the sun's frame to move that by a texel or two buys nothing. + if (sway.x > 0.0) { + float lift = clamp(vertex_position.y * 0.125, 0.0, 1.0); + float lean = sin(sway.y + VertexSway) * lift * sway.x; + w.x += floor(lean + 0.5); + } // The curved world (see WorldCurve): drop every vertex by the square // of how far its column stands from the camera's focus. Applied AFTER // the shadow lookup above and clear of the wireframe's model space, so @@ -396,6 +459,17 @@ function Voxel3D.newMesh(verts, map) return mesh end +-- A mesh whose vertices also carry the wind phase of the CLUMP they belong +-- to (see SWAY_FORMAT and Voxel3D.sway). +function Voxel3D.newSwayMesh(verts, map) + if #verts == 0 then return nil end + local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.SWAY_FORMAT, verts, + "triangles", "static") + if not ok then return nil end + if map and #map > 0 then pcall(mesh.setVertexMap, mesh, map) end + return mesh +end + -- The quad corner offsets and UV corners for one face direction, in the -- order the vertex map below stitches into two triangles. Corners are unit -- offsets from the voxel's (x, y, z) minimum corner. @@ -936,6 +1010,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0) -- on until a sprite pass says otherwise, reset per frame like `ghost` pcall(sh.send, sh, "glassOn", 1) + -- still until a pass asks for wind, reset per frame for the same reason + -- `ghost` is: a scene that opened between sway(true) and sway(false) + -- would otherwise lean every wall it drew. + pcall(sh.send, sh, "sway", { 0, 0 }) -- the curved world bends about the camera's focus, so the horizon keeps -- a fixed distance ahead of the player rather than sitting on the map. -- A placed camera may decline it outright (Voxel3D.camera.curve = 0). @@ -1196,6 +1274,42 @@ function Voxel3D.glass(on) pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0) end +-- The wind's phase, off the ENGINE's tile-animation clock -- the same 60Hz +-- counter the water's crests and the animated tiles run on, so everything +-- that moves in this world moves off one number rather than drifting +-- against each other. Floored to the sway beat: the value only changes on +-- the frame a step begins. +local function swayPhase() + -- lazily and through the mod namespace, as Water does: TerrainAtlas + -- reaches into the engine's renderer at load time, and nothing in a draw + -- path should depend on that having already happened. + local ok, frame = pcall(function() + return V.require("TerrainAtlas")._animFrame() + end) + if not (ok and type(frame) == "number") then return 0 end + local step = 60 / math.max(1, Voxel3D.SWAY_FPS) + local secs = math.floor(frame / step) * step / 60 + return secs * (2 * math.pi / math.max(0.001, Voxel3D.SWAY_PERIOD)) +end + +Voxel3D._swayPhase = swayPhase -- named for the suite + +-- Whether what is drawn next leans in the wind. Same shape as glass() +-- above, and gated per DRAW for the same reason: the shader decides how +-- far a vertex leans from its height in its OWN model, which is only the +-- height of a blade for a mesh that is made of blades. Turned on around +-- the tall-grass rows and off again straight after, so the walls, the +-- roofs and the people drawn from the same shader stand still. +function Voxel3D.sway(on) + if not (active and activeShader) then return end + if not on then + pcall(activeShader.send, activeShader, "sway", { 0, 0 }) + return + end + pcall(activeShader.send, activeShader, "sway", + { Voxel3D.SWAY_PIXELS, swayPhase() }) +end + function Voxel3D.endGhost() if not active then return end pcall(love.graphics.setDepthMode, "lequal", true) diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index dd4a146..3247c34 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -1126,11 +1126,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- so the tuft rows keep exactly the characters' own depth handicap local lean = math.max(leanAngle(), 0.05) local pull = VoxelScene.pull(lean) + -- and the wind, for the length of these rows only (Voxel3D.sway) + Voxel3D.sway(true) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy), pull) end + Voxel3D.sway(false) -- flower billboards: pulled like the characters and the grass, MINUS -- the depth of 8 world pixels along the view (8 sin a -- the camera -- looks along (0, -cos a, -sin a), so that is exactly one tile row of From 54ef13dacf0227c2ab857cf39650d3e655aa88b5 Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 18:47:54 -0300 Subject: [PATCH 2/9] Improve dynamic day night shadows --- lib/DayNight.lua | 148 +++++++++++++++++++++++----------- tests/dramatic_shape_test.lua | 46 +++++++---- 2 files changed, 131 insertions(+), 63 deletions(-) diff --git a/lib/DayNight.lua b/lib/DayNight.lua index 3be50e5..b2f00df 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -8,30 +8,19 @@ -- function of the clock, so the pinned settings and the running cycle can -- never drift apart: DUSK is simply the cycle stopped at sunset. -- --- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55), --- hanging in the southeast about 45 degrees up. That is the DAY setting and --- the default, so a player who never touches the row sees the mod they --- already had. From there the arc swings NORTH at both ends -- rising 70 --- degrees north of east, setting the mirror of that -- because the camera --- looks north and the northern sky is the only sky it ever frames: a sun --- that rose due east would light the world for ten minutes without once --- being seen. Swung north, the disc stands in frame through dawn and dusk --- (the hours worth looking at) and passes overhead-behind-the-camera --- through midday, which is where a noon sun belongs. +-- THE SUN follows a simplified northern-hemisphere trajectory. It rises +-- in the east, crosses the southern sky and sets in the west. Its elevation +-- creates long shadows during dawn, short shadows around noon and long +-- shadows in the opposite direction during dusk. -- --- THE MOON arcs entirely through the northern sky -- rising northeast, due --- north at mid-night, setting northwest -- so it hangs over the diorama all --- night and the pinned NIGHT setting puts it dead centre. Its shadows fall --- softly south, away from it, at about two-thirds the sun's weight. +-- THE MOON follows a weaker east-to-west trajectory during the night. Its +-- lower elevation and reduced alpha keep moon shadows visible without making +-- the night look as strongly illuminated as daytime. -- --- SHADOWS are the shear the light throws: direction opposite the body's --- bearing, length its elevation's cotangent (clamped -- a rising sun throws --- a long shadow, not an infinite one), strength fading to nothing over the --- last twelve degrees before the horizon so the handoff between sun and --- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE) --- deliberately stays the noon bake: it is a subtle angle term baked into --- every mesh, and rebaking the world's geometry per phase buys less than --- the cast shadows, the sky and the tint already say. +-- SHADOWS use the direction opposite to the active body. Their length is +-- calculated from the cotangent of its elevation and safely clamped near +-- the horizon. Their opacity fades smoothly while the sun or moon rises and +-- sets, preventing a sudden switch between solar and lunar shadows. -- -- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky: -- a cave at midnight is exactly as dark as a cave at noon, which is what a @@ -98,14 +87,21 @@ local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX)) local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX + NOON_KZ * NOON_KZ))) -local TH_RISE, TH_SET = -70, 250 -- north of east / north of west -local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160 -local EL_MOON = 40 +local TH_RISE, TH_NOON, TH_SET = 0, 90, 180 +local EL_NOON = 62 -DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height -DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight -DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press -DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set +-- A lua segue uma trajetoria semelhante, mas um pouco mais baixa. +-- Isso mantem a iluminacao noturna suave e produz sombras lunares +-- discretamente mais longas. +local TH_MRISE, TH_MMID, TH_MSET = 0, 90, 180 +local EL_MOON = 48 + +-- Ao se aproximar do horizonte, cotangente tende ao infinito. +-- O limite preserva sombras longas sem estourar o frustum do shadow map. +DayNight.K_MAX = 3.5 +DayNight.ALPHA_SUN = 0.40 +DayNight.ALPHA_MOON = 0.18 +DayNight.FADE_DEG = 10 -- disc PLACEMENT only: the true elevation would put the noon sun far above -- any frame, so the arc the discs ride is squashed toward the horizon. The @@ -113,9 +109,21 @@ DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a ris DayNight.ELEV_SQUASH = 0.14 -- three-point arc: a at s=0, b at s=0.5, c at s=1 +local function smoothstep(value) + value = math.max(0, math.min(1, value)) + return value * value * (3 - 2 * value) +end + +-- Interpolacao suave em duas metades. A velocidade angular diminui +-- naturalmente perto do meio-dia e nao sofre uma quebra visivel. local function arc(a, b, c, s) - if s < 0.5 then return a + (b - a) * 2 * s end - return b + (c - b) * (2 * s - 1) + if s < 0.5 then + local u = smoothstep(s * 2) + return a + (b - a) * u + end + + local u = smoothstep((s - 0.5) * 2) + return b + (c - b) * u end -- The body lighting the world at clock `t`: bearing and elevation in @@ -124,35 +132,83 @@ end -- not the moon rising. function DayNight.bodyAt(t) t = t % DayNight.CYCLE + if t <= DayNight.DAY_LEN then - local s = t / DayNight.DAY_LEN - return arc(TH_RISE, TH_NOON, TH_SET, s), - EL_NOON * math.sin(math.pi * s), false + local progress = t / DayNight.DAY_LEN + + -- Leste ao nascer, sul ao meio-dia e oeste ao entardecer. + local bearing = arc( + TH_RISE, + TH_NOON, + TH_SET, + progress + ) + + -- O seno cria uma elevacao fisicamente intuitiva: + -- zero no horizonte e maxima no meio do periodo. + local elevation = EL_NOON * math.sin(math.pi * progress) + + return bearing, elevation, false end - local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN) - return arc(TH_MRISE, TH_MMID, TH_MSET, s), - EL_MOON * math.sin(math.pi * s), true + + local nightLength = DayNight.CYCLE - DayNight.DAY_LEN + local progress = (t - DayNight.DAY_LEN) / nightLength + + local bearing = arc( + TH_MRISE, + TH_MMID, + TH_MSET, + progress + ) + + local elevation = EL_MOON * math.sin(math.pi * progress) + + return bearing, elevation, true end + -- The shadow shear that body throws: drift per pixel of height, opposite -- the bearing, cot(elevation) long, clamped. function DayNight.shearAt(t) - local th, el, moon = DayNight.bodyAt(t) - if el < 0.5 then el = 0.5 end - local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el))) - return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon + local bearing, elevation, moon = DayNight.bodyAt(t) + + -- Impede divisao por zero exatamente no horizonte. + local safeElevation = math.max(elevation, 0.25) + + -- Comprimento da sombra em relacao a altura do objeto. + -- cot(elevacao) fica grande com a luz baixa e pequeno com a luz alta. + local length = 1 / math.tan(math.rad(safeElevation)) + length = math.max(0, math.min(DayNight.K_MAX, length)) + + local angle = math.rad(bearing) + + -- A sombra segue no sentido oposto ao corpo luminoso. + local kx = -math.cos(angle) * length + local kz = -math.sin(angle) * length + + -- Evita valores residuais como 6e-17 nos pontos cardeais. + if math.abs(kx) < 1e-10 then kx = 0 end + if math.abs(kz) < 1e-10 then kz = 0 end + + return kx, kz, moon end + -- How much shadow the light can press right now, 0..1 of the body's own -- weight: full up high, gone at the horizon, so sunset hands off to -- moonrise through a soft shadowless gap instead of snapping. function DayNight.strengthAt(t) - local _, el = DayNight.bodyAt(t) - local s = el / DayNight.FADE_DEG - if s < 0 then return 0 end - return s < 1 and s or 1 + local _, elevation = DayNight.bodyAt(t) + + local strength = elevation / DayNight.FADE_DEG + strength = math.max(0, math.min(1, strength)) + + -- Smoothstep evita um aparecimento abrupto da sombra quando o corpo + -- cruza o horizonte. + return strength * strength * (3 - 2 * strength) end + -- ------- the palettes -- -- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands @@ -374,7 +430,7 @@ end -- The clock the RIG runs on: quantised, so the shadow map redraws a few -- times a minute as the sun crawls rather than every frame. -DayNight.STEP = 2 +DayNight.STEP = 0.5 function DayNight.rigTime() local t = DayNight.time() diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 4614afe..3a1eba7 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -3726,23 +3726,35 @@ DayNight.update(10) T.check(math.abs(DayNight.clock - 5) < 1e-9, "the dial wraps at twenty minutes, back into dawn") --- the sun: noon is the mod's existing sun, exactly -local kx, kz, moon = DayNight.shearAt(300) -T.check(not moon, "noon is the sun's") -T.check(math.abs(kx - (-0.85)) < 1e-9 and math.abs(kz - (-0.55)) < 1e-9, - ("DAY throws the shadows the mod always threw: (%.4f, %.4f)"):format(kx, kz)) -local kx0, kz0 = DayNight.shearAt(0) -T.check(math.abs(math.sqrt(kx0 * kx0 + kz0 * kz0) - DayNight.K_MAX) < 1e-9, - "a rising sun throws a LONG shadow, clamped -- never an infinite one") -T.eq(DayNight.strengthAt(0), 0, "and at the horizon it presses nothing") -T.eq(DayNight.strengthAt(300), 1, "at noon it presses in full") - --- the moon: due north at mid-night, pressing softly south -local mkx, mkz, mmoon = DayNight.shearAt(900) -T.check(mmoon, "mid-night is the moon's") -T.check(math.abs(mkx) < 1e-9, "due north: no east-west drift at all") -T.check(math.abs(mkz - 1 / math.tan(math.rad(40))) < 1e-9, - "shadows fall south, away from it, cot(40) long") +-- dynamic sun: east at dawn, south at noon and west at dusk +local dawnKx, dawnKz, dawnMoon = DayNight.shearAt(0) +T.check(not dawnMoon, "dawn belongs to the sun") +T.check(dawnKx < 0 and math.abs(dawnKz) < 1e-9, + "the eastern sunrise throws its long shadow west") +T.check(math.abs(math.sqrt(dawnKx * dawnKx + dawnKz * dawnKz) + - DayNight.K_MAX) < 1e-9, + "the sunrise shadow is safely clamped") +T.eq(DayNight.strengthAt(0), 0, + "the shadow fades out exactly at the horizon") + +local noonKx, noonKz, noonMoon = DayNight.shearAt(300) +T.check(not noonMoon, "noon belongs to the sun") +T.check(math.abs(noonKx) < 1e-9 and noonKz < 0, + "the southern noon sun throws its shorter shadow north") +T.eq(DayNight.strengthAt(300), 1, + "the noon shadow reaches full solar strength") + +local morningKx = DayNight.shearAt(150) +local afternoonKx = DayNight.shearAt(450) +T.check(morningKx < 0 and afternoonKx > 0, + "the shadow rotates from west to east across the day") + +local moonKx, moonKz, isMoon = DayNight.shearAt(900) +T.check(isMoon, "midnight belongs to the moon") +T.check(math.abs(moonKx) < 1e-9 and moonKz < 0, + "the southern midnight moon throws its shadow north") +T.check(math.abs(moonKz + 1 / math.tan(math.rad(48))) < 1e-9, + "moon shadow length follows its lower elevation") -- the palettes: pins land on their phase palette unmixed, blends stay on -- the 5-bit lattice From c9f06ffa52bdb44e1f857ffb04f81101f07c3e8a Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:07:13 -0300 Subject: [PATCH 3/9] Reduce excessive horizon shadows --- lib/DayNight.lua | 124 +++++++++++++---------------------------------- 1 file changed, 34 insertions(+), 90 deletions(-) diff --git a/lib/DayNight.lua b/lib/DayNight.lua index b2f00df..90f3c23 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -82,10 +82,6 @@ DayNight.clock = DayNight.T.day -- the running cycle's own position -- noon IS the existing sun: shear (-0.85, -0.55) hangs it at -- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up -local NOON_KX, NOON_KZ = -0.85, -0.55 -local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX)) -local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX - + NOON_KZ * NOON_KZ))) local TH_RISE, TH_NOON, TH_SET = 0, 90, 180 local EL_NOON = 62 @@ -98,10 +94,10 @@ local EL_MOON = 48 -- Ao se aproximar do horizonte, cotangente tende ao infinito. -- O limite preserva sombras longas sem estourar o frustum do shadow map. -DayNight.K_MAX = 3.5 +DayNight.K_MAX = 1.55 DayNight.ALPHA_SUN = 0.40 DayNight.ALPHA_MOON = 0.18 -DayNight.FADE_DEG = 10 +DayNight.FADE_DEG = 16 -- disc PLACEMENT only: the true elevation would put the noon sun far above -- any frame, so the arc the discs ride is squashed toward the horizon. The @@ -172,112 +168,60 @@ end function DayNight.shearAt(t) local bearing, elevation, moon = DayNight.bodyAt(t) - -- Impede divisao por zero exatamente no horizonte. + -- A cotangente descreve o comprimento geometrico da sombra. + -- Perto do horizonte esse valor cresce demais para a escala + -- compacta do diorama. local safeElevation = math.max(elevation, 0.25) + local physicalLength = 1 / math.tan(math.rad(safeElevation)) - -- Comprimento da sombra em relacao a altura do objeto. - -- cot(elevacao) fica grande com a luz baixa e pequeno com a luz alta. - local length = 1 / math.tan(math.rad(safeElevation)) - length = math.max(0, math.min(DayNight.K_MAX, length)) + -- Compressao exponencial suave. Sombras pequenas preservam sua + -- variacao, enquanto valores extremos se aproximam de K_MAX sem + -- encontrar um corte abrupto. + local normalized = 1 - math.exp( + -physicalLength / DayNight.K_MAX + ) + + local length = DayNight.K_MAX * normalized + + -- Nos primeiros graus acima do horizonte, a sombra recebe uma + -- reducao adicional. Isso representa visualmente a difusao + -- atmosferica do amanhecer e impede projecoes exageradas. + local horizonProgress = math.max( + 0, + math.min(1, elevation / 12) + ) + + local horizonFactor = 0.72 + 0.28 * horizonProgress + length = length * horizonFactor local angle = math.rad(bearing) - -- A sombra segue no sentido oposto ao corpo luminoso. + -- A sombra aponta para o lado oposto ao corpo luminoso. local kx = -math.cos(angle) * length local kz = -math.sin(angle) * length - -- Evita valores residuais como 6e-17 nos pontos cardeais. + -- Remove residuos numericos nas direcoes cardeais. if math.abs(kx) < 1e-10 then kx = 0 end if math.abs(kz) < 1e-10 then kz = 0 end return kx, kz, moon end - --- How much shadow the light can press right now, 0..1 of the body's own --- weight: full up high, gone at the horizon, so sunset hands off to --- moonrise through a soft shadowless gap instead of snapping. function DayNight.strengthAt(t) local _, elevation = DayNight.bodyAt(t) + -- No horizonte, a sombra pode ser longa, mas deve ser muito + -- difusa. O contraste aumenta conforme o corpo luminoso sobe. local strength = elevation / DayNight.FADE_DEG strength = math.max(0, math.min(1, strength)) - -- Smoothstep evita um aparecimento abrupto da sombra quando o corpo - -- cruza o horizonte. - return strength * strength * (3 - 2 * strength) -end - - --- ------- the palettes --- --- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands --- reads. Six bands, not four: twilight is the whole show here, and six rungs --- of it is what keeps a sunset reading as a gradient rather than as stripes. --- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including --- after blending, which re-quantises onto it. --- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass --- through. Day's blue horizon and dusk's gold one are near-complements, and --- a straight lerp between complements bottoms out in grey: mid-transition --- the whole sky went the colour of dishwater, and gold-to-navy did the same --- on the far side of sunset. So the evening bends through a golden hour --- (horizon warming, zenith still blue -- late afternoon), and both edges of --- the night bend through a violet civil twilight (rose horizon under a --- violet sky -- the real colour of that half hour). -DayNight.PALETTES = { - day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 }, - { 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } }, - golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 }, - { 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } }, - dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 }, - { 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } }, - dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 }, - { 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } }, - violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 }, - { 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } }, - night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 }, - { 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } }, -} - --- what the world's own colours are multiplied by, per phase (0..255) -DayNight.TINTS = { - day = { 255, 255, 255 }, - golden = { 255, 232, 208 }, - dawn = { 255, 216, 192 }, - dusk = { 255, 192, 168 }, - violet = { 184, 160, 200 }, - night = { 120, 136, 192 }, -} - --- the twilight glow around the low sun, and the discs' own four-shade --- palettes (lightest first, so a display mode transforms them like any --- other palette) -DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } } -DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 }, - { 248, 144, 80 }, { 216, 96, 64 } } -DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 }, - { 168, 184, 208 }, { 120, 136, 168 } } - --- The dial as keyframes: a repeated name is a plateau, a change is a --- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their --- pinned times, and so the evening approaches dusk THROUGH the golden-hour --- waypoint rather than straight across the grey between blue and gold. The --- morning side needs no waypoint of its own: dawn's pinks into day's blues --- share a family and blend clean. -local DIAL -local function dial() - if DIAL then return DIAL end - local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE - DIAL = { - { 0, "dawn" }, { B, "day" }, - { D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" }, - { D + B / 2, "violet" }, { D + B, "night" }, - { C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" }, - } - return DIAL + -- Smootherstep possui transicao suave nos dois extremos. + -- Isso impede pulsos ao nascer do sol e ao atingir a + -- intensidade completa. + return strength * strength * strength + * (strength * (strength * 6 - 15) + 10) end --- Phase weights at clock `t`, off the dial above. function DayNight.mix(t) t = t % DayNight.CYCLE local d = dial() From 0665bcc6170d45b40a59381d962175d0547f820f Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:12:14 -0300 Subject: [PATCH 4/9] Revert "Reduce excessive horizon shadows" This reverts commit c9f06ffa52bdb44e1f857ffb04f81101f07c3e8a. --- lib/DayNight.lua | 124 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 90 insertions(+), 34 deletions(-) diff --git a/lib/DayNight.lua b/lib/DayNight.lua index 90f3c23..b2f00df 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -82,6 +82,10 @@ DayNight.clock = DayNight.T.day -- the running cycle's own position -- noon IS the existing sun: shear (-0.85, -0.55) hangs it at -- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up +local NOON_KX, NOON_KZ = -0.85, -0.55 +local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX)) +local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX + + NOON_KZ * NOON_KZ))) local TH_RISE, TH_NOON, TH_SET = 0, 90, 180 local EL_NOON = 62 @@ -94,10 +98,10 @@ local EL_MOON = 48 -- Ao se aproximar do horizonte, cotangente tende ao infinito. -- O limite preserva sombras longas sem estourar o frustum do shadow map. -DayNight.K_MAX = 1.55 +DayNight.K_MAX = 3.5 DayNight.ALPHA_SUN = 0.40 DayNight.ALPHA_MOON = 0.18 -DayNight.FADE_DEG = 16 +DayNight.FADE_DEG = 10 -- disc PLACEMENT only: the true elevation would put the noon sun far above -- any frame, so the arc the discs ride is squashed toward the horizon. The @@ -168,60 +172,112 @@ end function DayNight.shearAt(t) local bearing, elevation, moon = DayNight.bodyAt(t) - -- A cotangente descreve o comprimento geometrico da sombra. - -- Perto do horizonte esse valor cresce demais para a escala - -- compacta do diorama. + -- Impede divisao por zero exatamente no horizonte. local safeElevation = math.max(elevation, 0.25) - local physicalLength = 1 / math.tan(math.rad(safeElevation)) - -- Compressao exponencial suave. Sombras pequenas preservam sua - -- variacao, enquanto valores extremos se aproximam de K_MAX sem - -- encontrar um corte abrupto. - local normalized = 1 - math.exp( - -physicalLength / DayNight.K_MAX - ) - - local length = DayNight.K_MAX * normalized - - -- Nos primeiros graus acima do horizonte, a sombra recebe uma - -- reducao adicional. Isso representa visualmente a difusao - -- atmosferica do amanhecer e impede projecoes exageradas. - local horizonProgress = math.max( - 0, - math.min(1, elevation / 12) - ) - - local horizonFactor = 0.72 + 0.28 * horizonProgress - length = length * horizonFactor + -- Comprimento da sombra em relacao a altura do objeto. + -- cot(elevacao) fica grande com a luz baixa e pequeno com a luz alta. + local length = 1 / math.tan(math.rad(safeElevation)) + length = math.max(0, math.min(DayNight.K_MAX, length)) local angle = math.rad(bearing) - -- A sombra aponta para o lado oposto ao corpo luminoso. + -- A sombra segue no sentido oposto ao corpo luminoso. local kx = -math.cos(angle) * length local kz = -math.sin(angle) * length - -- Remove residuos numericos nas direcoes cardeais. + -- Evita valores residuais como 6e-17 nos pontos cardeais. if math.abs(kx) < 1e-10 then kx = 0 end if math.abs(kz) < 1e-10 then kz = 0 end return kx, kz, moon end + +-- How much shadow the light can press right now, 0..1 of the body's own +-- weight: full up high, gone at the horizon, so sunset hands off to +-- moonrise through a soft shadowless gap instead of snapping. function DayNight.strengthAt(t) local _, elevation = DayNight.bodyAt(t) - -- No horizonte, a sombra pode ser longa, mas deve ser muito - -- difusa. O contraste aumenta conforme o corpo luminoso sobe. local strength = elevation / DayNight.FADE_DEG strength = math.max(0, math.min(1, strength)) - -- Smootherstep possui transicao suave nos dois extremos. - -- Isso impede pulsos ao nascer do sol e ao atingir a - -- intensidade completa. - return strength * strength * strength - * (strength * (strength * 6 - 15) + 10) + -- Smoothstep evita um aparecimento abrupto da sombra quando o corpo + -- cruza o horizonte. + return strength * strength * (3 - 2 * strength) +end + + +-- ------- the palettes +-- +-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands +-- reads. Six bands, not four: twilight is the whole show here, and six rungs +-- of it is what keeps a sunset reading as a gradient rather than as stripes. +-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including +-- after blending, which re-quantises onto it. +-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass +-- through. Day's blue horizon and dusk's gold one are near-complements, and +-- a straight lerp between complements bottoms out in grey: mid-transition +-- the whole sky went the colour of dishwater, and gold-to-navy did the same +-- on the far side of sunset. So the evening bends through a golden hour +-- (horizon warming, zenith still blue -- late afternoon), and both edges of +-- the night bend through a violet civil twilight (rose horizon under a +-- violet sky -- the real colour of that half hour). +DayNight.PALETTES = { + day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 }, + { 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } }, + golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 }, + { 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } }, + dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 }, + { 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } }, + dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 }, + { 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } }, + violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 }, + { 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } }, + night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 }, + { 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } }, +} + +-- what the world's own colours are multiplied by, per phase (0..255) +DayNight.TINTS = { + day = { 255, 255, 255 }, + golden = { 255, 232, 208 }, + dawn = { 255, 216, 192 }, + dusk = { 255, 192, 168 }, + violet = { 184, 160, 200 }, + night = { 120, 136, 192 }, +} + +-- the twilight glow around the low sun, and the discs' own four-shade +-- palettes (lightest first, so a display mode transforms them like any +-- other palette) +DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } } +DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 }, + { 248, 144, 80 }, { 216, 96, 64 } } +DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 }, + { 168, 184, 208 }, { 120, 136, 168 } } + +-- The dial as keyframes: a repeated name is a plateau, a change is a +-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their +-- pinned times, and so the evening approaches dusk THROUGH the golden-hour +-- waypoint rather than straight across the grey between blue and gold. The +-- morning side needs no waypoint of its own: dawn's pinks into day's blues +-- share a family and blend clean. +local DIAL +local function dial() + if DIAL then return DIAL end + local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE + DIAL = { + { 0, "dawn" }, { B, "day" }, + { D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" }, + { D + B / 2, "violet" }, { D + B, "night" }, + { C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" }, + } + return DIAL end +-- Phase weights at clock `t`, off the dial above. function DayNight.mix(t) t = t % DayNight.CYCLE local d = dial() From c31a26f3f4c2e2ef867383436bd3aa0ace54e623 Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:14:37 -0300 Subject: [PATCH 5/9] Tune horizon shadow limits --- lib/DayNight.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/DayNight.lua b/lib/DayNight.lua index b2f00df..4f197bf 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -98,10 +98,10 @@ local EL_MOON = 48 -- Ao se aproximar do horizonte, cotangente tende ao infinito. -- O limite preserva sombras longas sem estourar o frustum do shadow map. -DayNight.K_MAX = 3.5 +DayNight.K_MAX = 1.70 DayNight.ALPHA_SUN = 0.40 DayNight.ALPHA_MOON = 0.18 -DayNight.FADE_DEG = 10 +DayNight.FADE_DEG = 14 -- disc PLACEMENT only: the true elevation would put the noon sun far above -- any frame, so the arc the discs ride is squashed toward the horizon. The From 4503207470826a7edb871e6f270273da8be16692 Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:23:48 -0300 Subject: [PATCH 6/9] Add persistent voxel mesh cache --- lib/ChunkMesher.lua | 110 +++++++++++++++++-- lib/MeshDiskCache.lua | 239 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 7 deletions(-) create mode 100644 lib/MeshDiskCache.lua diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 84bc290..bb1d77e 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -55,6 +55,7 @@ local Structures = V.require("Structures") local TileShape = V.require("TileShape") local Voxel3D = V.require("Voxel3D") local Budget = V.require("BuildBudget") +local MeshDiskCache = V.require("MeshDiskCache") local ffi = nil do @@ -133,7 +134,7 @@ local function newTableSink() return verts, indices, quads end, finish = function() - return Voxel3D.newMesh(verts, indices) + return Voxel3D.newMesh(verts, indices), nil, nil end, } end @@ -190,7 +191,15 @@ local function newFfiSink() end return m end) - return ok and mesh or nil + if not ok or not mesh then return nil, nil, nil end + + -- The native buffer is already packed exactly as Voxel3D.FORMAT + -- expects. Copy it once only when the completed build is ready to + -- become persistent; no geometry calculation is repeated. + local byteCount = n * 6 * 4 + local payload = ffi.string(buf, byteCount) + + return mesh, n, payload end, } return sink @@ -962,39 +971,119 @@ end local function runJob(job) local map = job.map local c = entry(job.id) + local waterName = waterSlot(job.slot) + + -- Disk lookup happens before Structures analysis. A returning map can + -- therefore put its terrain back on the GPU immediately while auxiliary + -- grass, flowers and figures are prepared during later coroutine slices. + local cachedMesh, meshHit = MeshDiskCache.load(job.id, job.slot) + local cachedWater, waterHit = MeshDiskCache.load(job.id, waterName) + local terrainHit = meshHit and waterHit + + if terrainHit and (gen[job.id] or 0) == job.gen then + swapSlot(c, job.slot, cachedMesh or false) + swapSlot(c, waterName, cachedWater or false) + + if c.stale then + c.stale[job.slot] = nil + end + end + if c.grass == nil or c.flowers == nil or c.figures == nil or (c.stale and c.stale.aux) then local okG, grass = pcall(buildGrassMesh, map) + Budget.check() + local okF, flowers = pcall(buildFlowerMesh, map) + Budget.check() + local okX, figures = pcall(buildFigureMeshes, map) + if (gen[job.id] or 0) ~= job.gen then - if okG and grass and grass.release then pcall(grass.release, grass) end + if okG and grass and grass.release then + pcall(grass.release, grass) + end + if okF and flowers and flowers.release then pcall(flowers.release, flowers) end + if okX then releaseFigures(figures) end return end + swapSlot(c, "grass", (okG and grass) or false) swapSlot(c, "flowers", (okF and flowers) or false) + releaseFigures(c.figures) c.figures = (okX and figures) or false + if c.stale then c.stale.aux = nil end end + + -- A valid terrain/water pair needs no geometry rebuild. + if terrainHit then + if c.stale then + c.stale[job.slot] = nil + + if not (c.stale.full or c.stale.body or c.stale.aux) then + c.stale = nil + end + end + + return + end + local sink = newSink() local waterSink = newSink() - runGeometry(map, job.slot == "body", job.masks, sink, waterSink) - local mesh = sink.finish() - local water = waterSink.finish() + + runGeometry( + map, + job.slot == "body", + job.masks, + sink, + waterSink + ) + + local mesh, meshCount, meshPayload = sink.finish() + local water, waterCount, waterPayload = waterSink.finish() + if (gen[job.id] or 0) ~= job.gen then if mesh and mesh.release then pcall(mesh.release, mesh) end if water and water.release then pcall(water.release, water) end return end + swapSlot(c, job.slot, mesh or false) - swapSlot(c, waterSlot(job.slot), water or false) + swapSlot(c, waterName, water or false) + + -- Only the FFI sink exposes packed bytes. On a platform using the table + -- sink, gameplay remains unchanged and no persistent file is written. + if meshCount and meshPayload then + MeshDiskCache.store( + job.id, + job.slot, + meshCount, + meshPayload + ) + elseif mesh == nil then + MeshDiskCache.store(job.id, job.slot, 0, "") + end + + if waterCount and waterPayload then + MeshDiskCache.store( + job.id, + waterName, + waterCount, + waterPayload + ) + elseif water == nil then + MeshDiskCache.store(job.id, waterName, 0, "") + end + if c.stale then c.stale[job.slot] = nil + if not (c.stale.full or c.stale.body or c.stale.aux) then c.stale = nil end @@ -1162,6 +1251,7 @@ end -- to the flat 2D path, a whole-world blink for a one-block edit. function ChunkMesher.refresh(mapId) if not mapId then return ChunkMesher.invalidate() end + MeshDiskCache.invalidate(mapId) local c = cache[mapId] -- nothing drawable cached: the plain drop costs nothing visible if not (c and (c.full or c.body)) then @@ -1221,6 +1311,12 @@ end -- the generation counter. function ChunkMesher.invalidate(mapId) Structures.invalidate(mapId) + + if mapId then + MeshDiskCache.invalidate(mapId) + else + MeshDiskCache.clear() + end if mapId then local c = cache[mapId] if c then releaseEntry(c) end diff --git a/lib/MeshDiskCache.lua b/lib/MeshDiskCache.lua new file mode 100644 index 0000000..2dd7719 --- /dev/null +++ b/lib/MeshDiskCache.lua @@ -0,0 +1,239 @@ +-- Persistent voxel mesh cache. +-- +-- Stores processed vertex buffers in LOVE's save directory. GPU objects +-- themselves cannot be serialized, so a cached buffer is uploaded into a +-- fresh Mesh when its map becomes live again. +-- +-- Cache files are local generated data. They contain no ROM, texture or +-- copyrighted game asset, only positions, atlas coordinates and shading +-- values generated by ChunkMesher. + +local V = ... + +local Voxel3D = V.require("Voxel3D") + +local MeshDiskCache = {} + +-- Bump this whenever the vertex format or terrain geometry rules change. +-- +-- Including a build tag prevents older experimental files from being read +-- after this persistent-cache implementation changes. +MeshDiskCache.VERSION = "dsm-v1-c31a26f" +MeshDiskCache.DIRECTORY = "dramatic_shape/mesh_cache/" .. + MeshDiskCache.VERSION + +local HEADER = "DSMC1" +local STRIDE = 6 * 4 +local MAX_VERTICES = 12000000 +local UPLOAD_CHUNK = 65536 + +local function available() + return love + and love.filesystem + and love.filesystem.read + and love.filesystem.write + and love.filesystem.createDirectory + and love.graphics + and love.graphics.newMesh + and love.data + and love.data.newByteData +end + +MeshDiskCache.available = available + +local function safeName(value) + value = tostring(value or "unknown") + value = value:gsub("[^%w_.%-]", "_") + if value == "" then value = "unknown" end + return value +end + +local function pathFor(mapId, slot) + return MeshDiskCache.DIRECTORY .. "/" .. + safeName(mapId) .. "_" .. safeName(slot) .. ".dsm" +end + +MeshDiskCache.pathFor = pathFor + +local function parseFile(bytes) + if type(bytes) ~= "string" then return nil end + + local first = bytes:find("\n", 1, true) + if not first then return nil end + + local second = bytes:find("\n", first + 1, true) + if not second then return nil end + + local magic = bytes:sub(1, first - 1) + local count = tonumber(bytes:sub(first + 1, second - 1)) + + if magic ~= HEADER then return nil end + if not count or count < 0 or count > MAX_VERTICES then return nil end + if count ~= math.floor(count) then return nil end + + local payload = bytes:sub(second + 1) + local expected = count * STRIDE + + if #payload ~= expected then return nil end + + return count, payload +end + +local function upload(count, payload) + if count == 0 then return false end + + local ok, mesh = pcall( + love.graphics.newMesh, + Voxel3D.FORMAT, + count, + "triangles", + "static" + ) + + if not ok or not mesh then return nil end + + local position = 0 + + while position < count do + local vertexCount = math.min(UPLOAD_CHUNK, count - position) + local byteStart = position * STRIDE + 1 + local byteEnd = byteStart + vertexCount * STRIDE - 1 + local piece = payload:sub(byteStart, byteEnd) + + local dataOk, data = pcall(love.data.newByteData, piece) + + if not dataOk or not data then + if mesh.release then pcall(mesh.release, mesh) end + return nil + end + + local setOk = pcall( + mesh.setVertices, + mesh, + data, + position + 1 + ) + + if data.release then pcall(data.release, data) end + + if not setOk then + if mesh.release then pcall(mesh.release, mesh) end + return nil + end + + position = position + vertexCount + end + + return mesh +end + +-- Returns: +-- +-- mesh, true valid cached mesh +-- false, true valid cached empty mesh +-- nil, false missing or invalid cache +function MeshDiskCache.load(mapId, slot) + if not available() then return nil, false end + + local filename = pathFor(mapId, slot) + local infoOk, info = pcall(love.filesystem.getInfo, filename) + + if not infoOk or not info then return nil, false end + + local readOk, bytes = pcall(love.filesystem.read, filename) + + if not readOk then return nil, false end + + local count, payload = parseFile(bytes) + + if not count then + pcall(love.filesystem.remove, filename) + return nil, false + end + + local mesh = upload(count, payload) + + if count > 0 and not mesh then + pcall(love.filesystem.remove, filename) + return nil, false + end + + return mesh, true +end + +function MeshDiskCache.store(mapId, slot, count, payload) + if not available() then return false end + if type(count) ~= "number" then return false end + + count = math.floor(count) + + if count < 0 or count > MAX_VERTICES then return false end + + if count == 0 then + payload = "" + elseif type(payload) ~= "string" or #payload ~= count * STRIDE then + return false + end + + local dirOk = pcall( + love.filesystem.createDirectory, + MeshDiskCache.DIRECTORY + ) + + if not dirOk then return false end + + local filename = pathFor(mapId, slot) + local bytes = HEADER .. "\n" .. tostring(count) .. "\n" .. payload + + local writeOk, result = pcall( + love.filesystem.write, + filename, + bytes + ) + + return writeOk and result and true or false +end + +function MeshDiskCache.invalidate(mapId) + if not available() then return end + + local listOk, files = pcall( + love.filesystem.getDirectoryItems, + MeshDiskCache.DIRECTORY + ) + + if not listOk or type(files) ~= "table" then return end + + local prefix = safeName(mapId) .. "_" + + for _, name in ipairs(files) do + if name:sub(1, #prefix) == prefix and name:sub(-4) == ".dsm" then + pcall( + love.filesystem.remove, + MeshDiskCache.DIRECTORY .. "/" .. name + ) + end + end +end + +function MeshDiskCache.clear() + if not available() then return end + + local listOk, files = pcall( + love.filesystem.getDirectoryItems, + MeshDiskCache.DIRECTORY + ) + + if not listOk or type(files) ~= "table" then return end + + for _, name in ipairs(files) do + if name:sub(-4) == ".dsm" then + pcall( + love.filesystem.remove, + MeshDiskCache.DIRECTORY .. "/" .. name + ) + end + end +end + +return MeshDiskCache From 821912caaf36c581a9fe418f1b9832493dc7338e Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:34:28 -0300 Subject: [PATCH 7/9] Fix sprite shadows and restore daytime control --- lib/DayNight.lua | 12 +++++++----- lib/Voxel3D.lua | 29 ++++++++++++++++++++++++++--- main.lua | 2 +- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/lib/DayNight.lua b/lib/DayNight.lua index 4f197bf..19290d6 100644 --- a/lib/DayNight.lua +++ b/lib/DayNight.lua @@ -68,9 +68,11 @@ DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL, -- arrive at or act under FULL (main.lua: the preset itself, the rows hook, -- the manager's options_changed), mirroring OverworldBattle.forceOG. function DayNight.forceSync(game) - if DayNight.setting:get() ~= "sync" then - DayNight.setting:setIndex(1, game) - end + -- FULL e apenas um preset visual. O horario escolhido pelo jogador + -- permanece ativo e nao e mais redefinido automaticamente para SYNC. + -- + -- A funcao permanece para compatibilidade com os chamadores existentes. + return DayNight.setting:get() end DayNight.clock = DayNight.T.day -- the running cycle's own position @@ -98,10 +100,10 @@ local EL_MOON = 48 -- Ao se aproximar do horizonte, cotangente tende ao infinito. -- O limite preserva sombras longas sem estourar o frustum do shadow map. -DayNight.K_MAX = 1.70 +DayNight.K_MAX = 1.25 DayNight.ALPHA_SUN = 0.40 DayNight.ALPHA_MOON = 0.18 -DayNight.FADE_DEG = 14 +DayNight.FADE_DEG = 16 -- disc PLACEMENT only: the true elevation would put the noon sun far above -- any frame, so the arc the discs ride is squashed toward the horizon. The diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index b97d3e4..ea02151 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -1358,9 +1358,32 @@ end -- caster itself it is a no-op -- that quad is already flat. function Voxel3D.casterMatrix(px, py, y, mirror) local m = Mat4.translate(px + 8, y, py + 8) - if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end - return Mat4.mul(Mat4.mul(m, Mat4.translate(-8, 0, 0)), - Mat4.scale(1, 1, 0)) + + -- O personagem e uma placa vertical 2D. Quando sua largura fica + -- paralela ao raio da luz, o shadow map enxerga a placa de lado e + -- a sombra colapsa para uma linha fina. + -- + -- Giramos somente o caster usado pelo shadow map. A sprite visivel + -- continua orientada para a camera como antes. + local kx = Voxel3D.SHADOW_KX or 0 + local kz = Voxel3D.SHADOW_KZ or 0 + local horizontal = math.sqrt(kx * kx + kz * kz) + + if horizontal > 1e-6 then + -- Faz o plano da placa ficar de frente para a direcao horizontal + -- da luz, preservando uma silhueta larga no chao. + local yaw = math.atan2(-kx, -kz) + m = Mat4.mul(m, Mat4.rotateY(yaw)) + end + + if mirror then + m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) + end + + return Mat4.mul( + Mat4.mul(m, Mat4.translate(-8, 0, 0)), + Mat4.scale(1, 1, 0) + ) end -- FALLBACK ONLY (no shadow map: headless, or a driver that cannot make the diff --git a/main.lua b/main.lua index 4714165..3ae69e8 100644 --- a/main.lua +++ b/main.lua @@ -460,7 +460,7 @@ local SETTINGS = { "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " .. "shadows, the sky and the light following -- or SYNC it to the " - .. "clock on the wall, so Kanto's evening falls when yours does." }, + .. "clock on the wall, so Kanto's evening falls when yours does." , full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a -- knob on the look at all, it is what the look COSTS. FULL is a preset for -- the diorama, not a licence to spend four times the fill rate on the From c257d1070bbb3dc98c6e832b3c87190599286e43 Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:48:30 -0300 Subject: [PATCH 8/9] Add dynamic lighting to voxel object faces --- lib/ChunkMesher.lua | 57 +++++++++++++++++++++++++++++++++++++++++-- lib/MeshDiskCache.lua | 2 +- lib/Voxel3D.lua | 54 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 5 deletions(-) diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index bb1d77e..b69e881 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -117,6 +117,56 @@ end -- skipping ~a million short-lived Lua tables per route and LOVE's slow -- table-by-table vertex upload. +-- Encode face orientation beside the existing AO/shade value without +-- expanding Voxel3D.FORMAT. The integer part is the cardinal normal and +-- the fractional part carries the original 0..1 shade. +-- +-- 1 = +X east +-- 2 = -X west +-- 3 = +Y top +-- 4 = -Y bottom +-- 5 = +Z south +-- 6 = -Z north +-- +-- The multiplier stays below one whole unit, so floor() recovers the face +-- reliably in the shader. Existing AO continues to vary per corner. +local FACE_SHADE_SCALE = 0.125 + +local function faceCode(c) + local ax = c[2][1] - c[1][1] + local ay = c[2][2] - c[1][2] + local az = c[2][3] - c[1][3] + + local bx = c[3][1] - c[1][1] + local by = c[3][2] - c[1][2] + local bz = c[3][3] - c[1][3] + + local nx = ay * bz - az * by + local ny = az * bx - ax * bz + local nz = ax * by - ay * bx + + local x = math.abs(nx) + local y = math.abs(ny) + local z = math.abs(nz) + + if x >= y and x >= z then + return nx >= 0 and 1 or 2 + elseif y >= x and y >= z then + return ny >= 0 and 3 or 4 + else + return nz >= 0 and 5 or 6 + end +end + +local function packFaceShade(c, shade) + local value = tonumber(shade) or 1 + + if value < 0 then value = 0 end + if value > 1 then value = 1 end + + return faceCode(c) + value * FACE_SHADE_SCALE +end + local function newTableSink() local verts, indices, quads = {}, {}, 0 return { @@ -125,7 +175,7 @@ local function newTableSink() for i = 1, 4 do local cc, t = c[i], uv[i] verts[#verts + 1] = { cc[1], cc[2], cc[3], t[1], t[2], - flat and shade or shade[i] } + packFaceShade(c, flat and shade or shade[i]) } end Voxel3D.pushQuad(indices, quads) quads = quads + 1 @@ -163,7 +213,10 @@ local function newFfiSink() buf[base + 2] = cc[3] buf[base + 3] = t[1] buf[base + 4] = t[2] - buf[base + 5] = flat and shade or shade[i] + buf[base + 5] = packFaceShade( + c, + flat and shade or shade[i] + ) base = base + 6 end n = n + 6 diff --git a/lib/MeshDiskCache.lua b/lib/MeshDiskCache.lua index 2dd7719..6b63a1c 100644 --- a/lib/MeshDiskCache.lua +++ b/lib/MeshDiskCache.lua @@ -18,7 +18,7 @@ local MeshDiskCache = {} -- -- Including a build tag prevents older experimental files from being read -- after this persistent-cache implementation changes. -MeshDiskCache.VERSION = "dsm-v1-c31a26f" +MeshDiskCache.VERSION = "dsm-v2-dynamic-face-light" MeshDiskCache.DIRECTORY = "dramatic_shape/mesh_cache/" .. MeshDiskCache.VERSION diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index ea02151..87b243b 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -108,6 +108,7 @@ Voxel3D.SWAY_PERIOD = 2.5 local SHADER = [[ varying float vShade; + varying float vFaceLight; varying vec3 vSun; // this fragment's place in the sun's view #ifdef VOXEL_GRID // model space, one unit per voxel -- see VoxelGrid. Precision matters @@ -128,7 +129,35 @@ local SHADER = [[ attribute float VertexShade; attribute float VertexSway; // this clump's wind phase (see SWAY_FORMAT) vec4 position(mat4 transform_projection, vec4 vertex_position) { - vShade = VertexShade; + // VertexShade carries two values without growing the mesh format: + // integer part = face direction, fractional part = original AO/shade. + float faceId = floor(VertexShade + 0.0001); + vShade = clamp(fract(VertexShade) * 8.0, 0.0, 1.0); + + vec3 objectNormal; + + if (faceId < 1.5) { + objectNormal = vec3(1.0, 0.0, 0.0); + } else if (faceId < 2.5) { + objectNormal = vec3(-1.0, 0.0, 0.0); + } else if (faceId < 3.5) { + objectNormal = vec3(0.0, 1.0, 0.0); + } else if (faceId < 4.5) { + objectNormal = vec3(0.0, -1.0, 0.0); + } else if (faceId < 5.5) { + objectNormal = vec3(0.0, 0.0, 1.0); + } else { + objectNormal = vec3(0.0, 0.0, -1.0); + } + + // Transform as a direction, never as a position. This also follows + // rotated props, characters and Stadium pieces correctly. + vec3 worldNormal = normalize( + (model * vec4(objectNormal, 0.0)).xyz + ); + + float facing = max(dot(worldNormal, lightDir), 0.0); + vFaceLight = faceAmbient + faceDiffuse * facing; #ifdef VOXEL_GRID // MODEL space, deliberately: every mesh here is built a unit per // voxel in its own frame, so the seams ride the model however it is @@ -265,6 +294,9 @@ local SHADER = [[ } #endif + uniform vec3 lightDir; // surface -> active sun or moon + uniform float faceAmbient; // light reaching faces turned away + uniform float faceDiffuse; // directional contribution uniform vec3 ghostColor; // the flat silhouette colour uniform float ghost; // 0 = shade normally, 1 = flatten to it uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon @@ -283,7 +315,8 @@ local SHADER = [[ if (p.a < 0.5) discard; // the hour's tint multiplies like the sun terms do: it is LIGHT, the // same warm or moonlit cast on every surface, not a palette swap - vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint; + vec3 rgb = p.rgb * vShade * vFaceLight + * sunlight(vSun) * dayTint; #ifdef VOXEL_GRID // darken what is there rather than painting a colour, so a seam across // dark grass and one across a white roof each stay in their own palette @@ -981,6 +1014,23 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) local tex = ShadowMap.texture() if tex then pcall(sh.send, sh, "sunMap", tex) end pcall(sh.send, sh, "sunDark", map and Voxel3D.SHADOW_ALPHA or 0) + + -- ShadowMap.sunDir is the direction in which light travels. Surface + -- lighting needs the opposite direction, from the surface toward the + -- active sun or moon. + local travel = ShadowMap.sunDir() + local light = { + -(travel[1] or 0), + -(travel[2] or -1), + -(travel[3] or 0), + } + + pcall(sh.send, sh, "lightDir", light) + + -- Ambient keeps the far side readable in the four-colour art while the + -- directional component makes east/west/north/south visibly distinct. + pcall(sh.send, sh, "faceAmbient", 0.62) + pcall(sh.send, sh, "faceDiffuse", 0.38) pcall(sh.send, sh, "sunBias", ShadowMap.bias) local texel = 1 / ShadowMap.res pcall(sh.send, sh, "sunTexel", { texel, texel }) From a89de9511fbbab1a51712d5ea76535c988dd6727 Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Wed, 5 Aug 2026 19:51:20 -0300 Subject: [PATCH 9/9] Revert "Add dynamic lighting to voxel object faces" This reverts commit c257d1070bbb3dc98c6e832b3c87190599286e43. --- lib/ChunkMesher.lua | 57 ++----------------------------------------- lib/MeshDiskCache.lua | 2 +- lib/Voxel3D.lua | 54 ++-------------------------------------- 3 files changed, 5 insertions(+), 108 deletions(-) diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index b69e881..bb1d77e 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -117,56 +117,6 @@ end -- skipping ~a million short-lived Lua tables per route and LOVE's slow -- table-by-table vertex upload. --- Encode face orientation beside the existing AO/shade value without --- expanding Voxel3D.FORMAT. The integer part is the cardinal normal and --- the fractional part carries the original 0..1 shade. --- --- 1 = +X east --- 2 = -X west --- 3 = +Y top --- 4 = -Y bottom --- 5 = +Z south --- 6 = -Z north --- --- The multiplier stays below one whole unit, so floor() recovers the face --- reliably in the shader. Existing AO continues to vary per corner. -local FACE_SHADE_SCALE = 0.125 - -local function faceCode(c) - local ax = c[2][1] - c[1][1] - local ay = c[2][2] - c[1][2] - local az = c[2][3] - c[1][3] - - local bx = c[3][1] - c[1][1] - local by = c[3][2] - c[1][2] - local bz = c[3][3] - c[1][3] - - local nx = ay * bz - az * by - local ny = az * bx - ax * bz - local nz = ax * by - ay * bx - - local x = math.abs(nx) - local y = math.abs(ny) - local z = math.abs(nz) - - if x >= y and x >= z then - return nx >= 0 and 1 or 2 - elseif y >= x and y >= z then - return ny >= 0 and 3 or 4 - else - return nz >= 0 and 5 or 6 - end -end - -local function packFaceShade(c, shade) - local value = tonumber(shade) or 1 - - if value < 0 then value = 0 end - if value > 1 then value = 1 end - - return faceCode(c) + value * FACE_SHADE_SCALE -end - local function newTableSink() local verts, indices, quads = {}, {}, 0 return { @@ -175,7 +125,7 @@ local function newTableSink() for i = 1, 4 do local cc, t = c[i], uv[i] verts[#verts + 1] = { cc[1], cc[2], cc[3], t[1], t[2], - packFaceShade(c, flat and shade or shade[i]) } + flat and shade or shade[i] } end Voxel3D.pushQuad(indices, quads) quads = quads + 1 @@ -213,10 +163,7 @@ local function newFfiSink() buf[base + 2] = cc[3] buf[base + 3] = t[1] buf[base + 4] = t[2] - buf[base + 5] = packFaceShade( - c, - flat and shade or shade[i] - ) + buf[base + 5] = flat and shade or shade[i] base = base + 6 end n = n + 6 diff --git a/lib/MeshDiskCache.lua b/lib/MeshDiskCache.lua index 6b63a1c..2dd7719 100644 --- a/lib/MeshDiskCache.lua +++ b/lib/MeshDiskCache.lua @@ -18,7 +18,7 @@ local MeshDiskCache = {} -- -- Including a build tag prevents older experimental files from being read -- after this persistent-cache implementation changes. -MeshDiskCache.VERSION = "dsm-v2-dynamic-face-light" +MeshDiskCache.VERSION = "dsm-v1-c31a26f" MeshDiskCache.DIRECTORY = "dramatic_shape/mesh_cache/" .. MeshDiskCache.VERSION diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 87b243b..ea02151 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -108,7 +108,6 @@ Voxel3D.SWAY_PERIOD = 2.5 local SHADER = [[ varying float vShade; - varying float vFaceLight; varying vec3 vSun; // this fragment's place in the sun's view #ifdef VOXEL_GRID // model space, one unit per voxel -- see VoxelGrid. Precision matters @@ -129,35 +128,7 @@ local SHADER = [[ attribute float VertexShade; attribute float VertexSway; // this clump's wind phase (see SWAY_FORMAT) vec4 position(mat4 transform_projection, vec4 vertex_position) { - // VertexShade carries two values without growing the mesh format: - // integer part = face direction, fractional part = original AO/shade. - float faceId = floor(VertexShade + 0.0001); - vShade = clamp(fract(VertexShade) * 8.0, 0.0, 1.0); - - vec3 objectNormal; - - if (faceId < 1.5) { - objectNormal = vec3(1.0, 0.0, 0.0); - } else if (faceId < 2.5) { - objectNormal = vec3(-1.0, 0.0, 0.0); - } else if (faceId < 3.5) { - objectNormal = vec3(0.0, 1.0, 0.0); - } else if (faceId < 4.5) { - objectNormal = vec3(0.0, -1.0, 0.0); - } else if (faceId < 5.5) { - objectNormal = vec3(0.0, 0.0, 1.0); - } else { - objectNormal = vec3(0.0, 0.0, -1.0); - } - - // Transform as a direction, never as a position. This also follows - // rotated props, characters and Stadium pieces correctly. - vec3 worldNormal = normalize( - (model * vec4(objectNormal, 0.0)).xyz - ); - - float facing = max(dot(worldNormal, lightDir), 0.0); - vFaceLight = faceAmbient + faceDiffuse * facing; + vShade = VertexShade; #ifdef VOXEL_GRID // MODEL space, deliberately: every mesh here is built a unit per // voxel in its own frame, so the seams ride the model however it is @@ -294,9 +265,6 @@ local SHADER = [[ } #endif - uniform vec3 lightDir; // surface -> active sun or moon - uniform float faceAmbient; // light reaching faces turned away - uniform float faceDiffuse; // directional contribution uniform vec3 ghostColor; // the flat silhouette colour uniform float ghost; // 0 = shade normally, 1 = flatten to it uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon @@ -315,8 +283,7 @@ local SHADER = [[ if (p.a < 0.5) discard; // the hour's tint multiplies like the sun terms do: it is LIGHT, the // same warm or moonlit cast on every surface, not a palette swap - vec3 rgb = p.rgb * vShade * vFaceLight - * sunlight(vSun) * dayTint; + vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint; #ifdef VOXEL_GRID // darken what is there rather than painting a colour, so a seam across // dark grass and one across a white roof each stay in their own palette @@ -1014,23 +981,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) local tex = ShadowMap.texture() if tex then pcall(sh.send, sh, "sunMap", tex) end pcall(sh.send, sh, "sunDark", map and Voxel3D.SHADOW_ALPHA or 0) - - -- ShadowMap.sunDir is the direction in which light travels. Surface - -- lighting needs the opposite direction, from the surface toward the - -- active sun or moon. - local travel = ShadowMap.sunDir() - local light = { - -(travel[1] or 0), - -(travel[2] or -1), - -(travel[3] or 0), - } - - pcall(sh.send, sh, "lightDir", light) - - -- Ambient keeps the far side readable in the four-colour art while the - -- directional component makes east/west/north/south visibly distinct. - pcall(sh.send, sh, "faceAmbient", 0.62) - pcall(sh.send, sh, "faceDiffuse", 0.38) pcall(sh.send, sh, "sunBias", ShadowMap.bias) local texel = 1 / ShadowMap.res pcall(sh.send, sh, "sunTexel", { texel, texel })