From a46f194009f7d9dc03d3ce466864cfa8b40790a1 Mon Sep 17 00:00:00 2001 From: Davi D'artemis Date: Fri, 7 Aug 2026 04:38:41 -0300 Subject: [PATCH] Visual improvements: grass movement --- lib/ChunkMesher.lua | 11 +++++-- lib/Structures.lua | 6 +++- lib/Voxel3D.lua | 76 +++++++++++++++++++++++++++++++++++++++++++++ lib/VoxelScene.lua | 17 ++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index eb2dbb9..6213260 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -812,18 +812,23 @@ function ChunkMesher.build(map, bodyOnly, masks, split) return sink.finish(), waterSink and waterSink.finish() or nil end -local function quadsMesh(quads) +local function quadsMesh(quads, grass) if #quads == 0 then return nil end local verts, indices, n = {}, {}, 0 for _, q in ipairs(quads) do 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 } + local v = { c[1], c[2], c[3], uv[1], uv[2], q.shade } + if grass then + v[7], v[8], v[9] = q.sway or 0, q.cx or 0, q.cz or 0 + end + verts[#verts + 1] = v end Voxel3D.pushQuad(indices, n) n = n + 1 end + if grass then return Voxel3D.newGrassMesh(verts, indices) end return Voxel3D.newMesh(verts, indices) end @@ -832,7 +837,7 @@ end -- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary -- terrain could never do this). local function buildGrassMesh(map) - return quadsMesh(Structures.forMap(map).grassQuads) + return quadsMesh(Structures.forMap(map).grassQuads, true) end -- The flower billboards as their own mesh, for the same reason as the diff --git a/lib/Structures.lua b/lib/Structures.lua index 725dbac..0f971e0 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -3508,13 +3508,17 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data) templates[tileId] = tpl end local wx, wz = tx * 8, ty * 8 + -- Stable diagonal phase per tuft. Both ends of every quad receive + -- the same value, so a gust bends the slab without shearing it. + local sway = wx * 0.050 + wz * 0.031 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 = sway, + cx = wx + 4, cz = wz + 4, } end end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index d7dcefc..8257f0f 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -48,6 +48,21 @@ Voxel3D.FORMAT = { { "VertexShade", "float", 1 }, } +-- Tall grass carries one extra value: a stable phase shared by every +-- vertex in a tuft. Keeping it constant prevents the two ends of a blade +-- from shearing apart while the gust travels across the map. +Voxel3D.GRASS_FORMAT = { + { "VertexPosition", "float", 3 }, + { "VertexTexCoord", "float", 2 }, + { "VertexShade", "float", 1 }, + { "VertexGrass", "float", 3 }, -- phase, clump centre x, clump centre z +} + +Voxel3D.GRASS_WIND_PIXELS = 1.15 +Voxel3D.GRASS_WIND_SPEED = 2.35 +Voxel3D.GRASS_INTERACT_RADIUS = 12 +Voxel3D.GRASS_INTERACT_PIXELS = 2.5 + -- 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 @@ -96,7 +111,11 @@ local SHADER = [[ uniform float pull; uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off uniform vec4 fogInfo; // density, start, heightK; density 0 = clear + uniform vec4 grassWind; // enabled, time, wind pixels, speed + uniform vec4 grassPlayer; // world x, world z, radius, push pixels + uniform vec2 grassPrevious; // previous player world xz for swept contact attribute float VertexShade; + attribute vec3 VertexGrass; vec4 position(mat4 transform_projection, vec4 vertex_position) { vShade = VertexShade; #ifdef VOXEL_GRID @@ -117,6 +136,32 @@ 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 and player contact are applied in world space, before the curved + // world and camera pull. vertex y is 0..8 for these tuft meshes, which + // pins the root and lets the tip receive the full displacement. + if (grassWind.x > 0.5) { + float bend = clamp(vertex_position.y / 8.0, 0.0, 1.0); + bend *= bend; + float wave = sin(grassWind.y * grassWind.w + VertexGrass.x); + vec2 offset = vec2(wave * grassWind.z, 0.0); + + // Move each touched tuft rigidly a little to the left or right. The + // baked clump centre is identical for all its vertices, preventing + // stretching, twisting and the ugly crushed-grass silhouette. + vec2 clump = VertexGrass.yz; + float bodyDist = length(clump - grassPlayer.xy); + float touch = 1.0 - smoothstep(grassPlayer.z * 0.45, + grassPlayer.z, bodyDist); + float side = clump.x < grassPlayer.x ? -1.0 : 1.0; + if (abs(clump.x - grassPlayer.x) < 0.5) + side = sin(VertexGrass.x) < 0.0 ? -1.0 : 1.0; + + // Preserve the existing wind bend. Player contact is only a small, + // uniform X translation, just enough to suggest the grass parting. + w.xz += offset * bend; + w.x += side * touch * grassPlayer.w; + } // THE MAP'S HAZE (see ForestAtmos): how much fog stands between the // eye and this vertex -- distance dissolves into it, altitude climbs // out of it. Worked out on the FLAT world like the shadow lookup @@ -496,6 +541,15 @@ function Voxel3D.newMesh(verts, map) return mesh end +function Voxel3D.newGrassMesh(verts, map) + if #verts == 0 then return nil end + local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.GRASS_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. @@ -1089,6 +1143,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 the dedicated grass pass enables it + pcall(sh.send, sh, "grassWind", { 0, 0, 0, 0 }) + pcall(sh.send, sh, "grassPlayer", { -100000, -100000, 1, 0 }) + pcall(sh.send, sh, "grassPrevious", { -100000, -100000 }) -- 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). @@ -1368,6 +1426,24 @@ function Voxel3D.glass(on) pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0) end +-- Enable wind only around tall-grass draws. px/pz are the current player's +-- feet in world pixels; previous values make collision continuous per frame. +function Voxel3D.grassWind(on, px, pz, previousX, previousZ) + if not (active and activeShader) then return end + if not on then + pcall(activeShader.send, activeShader, "grassWind", { 0, 0, 0, 0 }) + return + end + local now = love.timer and love.timer.getTime and love.timer.getTime() or 0 + pcall(activeShader.send, activeShader, "grassWind", + { 1, now, Voxel3D.GRASS_WIND_PIXELS, Voxel3D.GRASS_WIND_SPEED }) + pcall(activeShader.send, activeShader, "grassPlayer", + { px or -100000, pz or -100000, + Voxel3D.GRASS_INTERACT_RADIUS, Voxel3D.GRASS_INTERACT_PIXELS }) + pcall(activeShader.send, activeShader, "grassPrevious", + { previousX or px or -100000, previousZ or pz or -100000 }) +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 85fcddd..95cfb92 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -1141,11 +1141,28 @@ 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) + -- Character px/py is anchored on the 16 px card. Its world centre/feet + -- contact used by the camera code is +8,+8, so use the same point here. + -- Keep the prior rendered point so fast steps sweep through every tuft. + local gx, gz = -100000, -100000 + if me then gx, gz = me.px + 8, me.py + 8 end + VoxelScene._grassPrevX = VoxelScene._grassPrevX or gx + VoxelScene._grassPrevZ = VoxelScene._grassPrevZ or gz + -- A warp/map transition is not a walk. Do not sweep one enormous contact + -- segment across the new map when the player jumps more than two tiles. + local gdx, gdz = gx - VoxelScene._grassPrevX, gz - VoxelScene._grassPrevZ + if gdx * gdx + gdz * gdz > 32 * 32 then + VoxelScene._grassPrevX, VoxelScene._grassPrevZ = gx, gz + end + Voxel3D.grassWind(true, gx, gz, + VoxelScene._grassPrevX, VoxelScene._grassPrevZ) 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.grassWind(false) + VoxelScene._grassPrevX, VoxelScene._grassPrevZ = gx, gz -- 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