Merge pull request #138 from daviDarthemis/dev

Grass improvements
This commit is contained in:
DramaticShape
2026-08-08 08:27:19 -04:00
committed by GitHub
4 changed files with 178 additions and 4 deletions
+10 -3
View File
@@ -812,18 +812,25 @@ 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], v[10] = q.sway or 0, q.cx or 0,
q.cz or 0,
q.firefly and 2 or (q.leaf and 1 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 +839,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
+37 -1
View File
@@ -3508,13 +3508,49 @@ 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
-- Sparse wind-borne leaf. It reuses one opaque grass texel and is
-- animated entirely on the GPU, so no per-frame Lua particles exist.
if #tpl > 0 and ((tx * 13 + ty * 7) % 11 == 0) then
local src = tpl[1]
local uv = src.uv and src.uv[1] or { src.u, src.v }
local lx = wx + 2 + ((tx * 5 + ty * 3) % 5)
local lz = wz + 4
local ly, size = 9 + ((tx + ty) % 3), 1.25
quads[#quads + 1] = {
{ lx - size, ly, lz }, { lx + size, ly, lz },
{ lx + size, ly + size, lz }, { lx - size, ly + size, lz },
uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 0.73,
cx = lx, cz = lz, leaf = true,
}
end
-- Rarer one-pixel firefly. Geometry exists all day, but the shader
-- gives it zero glow outside outdoor night.
if #tpl > 0 and ((tx * 17 + ty * 11) % 29 == 0) then
local src = tpl[1]
local uv = src.uv and src.uv[1] or { src.u, src.v }
local fx = wx + 2 + ((tx * 3 + ty * 5) % 5)
local fz = wz + 4
local fy, half = 9 + ((tx + ty) % 4), 0.5
quads[#quads + 1] = {
{ fx - half, fy, fz }, { fx + half, fy, fz },
{ fx + half, fy + 1, fz }, { fx - half, fy + 1, fz },
uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 1.37,
cx = fx, cz = fz, firefly = true,
}
end
end
+111
View File
@@ -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", 4 }, -- phase, clump centre x/z, effect kind
}
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
@@ -73,6 +88,8 @@ local SHADER = [[
varying float vShade;
varying vec3 vSun; // this fragment's place in the sun's view
varying float vFog; // how deep into the map's haze it stands
varying float vFirefly; // zero normally, night glow on firefly cards
uniform float fireflyNight; // shared safely by vertex and pixel stages
#ifdef VOXEL_CULL
// where this fragment stands in the FLAT world, for the diorama's
// viewport to measure. Same precision reasoning as vGrid below: a
@@ -96,9 +113,14 @@ 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 vec4 VertexGrass;
vec4 position(mat4 transform_projection, vec4 vertex_position) {
vShade = VertexShade;
vFirefly = 0.0;
#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
@@ -117,6 +139,60 @@ 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);
if (VertexGrass.w > 1.5) {
// One-pixel firefly with a full behaviour loop: a long rest on the
// grass, smooth take-off, an irregular short flight, descent, landing
// and another pause. The baked phase keeps every insect independent.
float cycle = fract(grassWind.y * 0.052 + VertexGrass.x * 0.173);
float takeoff = smoothstep(0.30, 0.39, cycle);
float landing = 1.0 - smoothstep(0.68, 0.79, cycle);
float airborne = takeoff * landing;
float drift = grassWind.y * 0.83 + VertexGrass.x * 3.7;
float wander = sin(drift) * 2.8 + sin(drift * 0.37 + 1.3) * 1.5;
float lift = 2.5 + sin(drift * 1.19) * 1.1
+ sin(drift * 0.53 + 0.8) * 0.7;
w.x += airborne * wander;
w.y += airborne * lift;
// Mostly dim while resting, visibly brighter in flight, with a soft
// asynchronous pulse rather than a hard on/off blink.
float blink = 0.68 + 0.32 * (sin(drift * 2.11) * 0.5 + 0.5);
vFirefly = fireflyNight * blink * mix(0.14, 0.92, airborne);
} else if (VertexGrass.w > 0.5) {
// The first 72% of the cycle is airborne. A leaf gets an initial
// upward lift, travels with the wind, then gravity accelerates it
// down to the ground. It rests there for the remainder before a new
// leaf is emitted. Z remains fixed, preserving sprite depth order.
float life = fract(grassWind.y * 0.085 + VertexGrass.x * 0.159);
float fall = min(life / 0.72, 1.0);
float travel = fall * 44.0 - 6.0;
float flutter = grassWind.y * 3.0 + VertexGrass.x * 4.7;
float lift = sin(fall * 3.14159265) * 5.0;
float gravity = 9.0 * fall * fall;
float flutterFade = 1.0 - smoothstep(0.62, 1.0, fall);
w.x += travel + sin(flutter) * 1.2 * flutterFade;
w.y += lift - gravity
+ sin(flutter * 0.61) * 0.8 * flutterFade;
} else {
vec2 offset = vec2(wave * grassWind.z, 0.0);
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;
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
@@ -353,6 +429,9 @@ local SHADER = [[
// solid silhouette. Last in the chain, so neither the sun nor a voxel
// seam can mottle it.
rgb = mix(rgb, ghostColor, ghost);
// Emissive but still coloured: increasingly visible as Lua raises the
// night factor, without adding more insects or washing the scene white.
rgb = mix(rgb, vec3(0.82, 1.00, 0.22), vFirefly);
// the viewport's rim is an ALPHA, so the last of the model blends into
// whatever the frame opened with -- the sky, or the chroma key. 1
// everywhere without the cut compiled in, which is every flat frame.
@@ -505,6 +584,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.
@@ -1071,6 +1159,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
-- the hour's light, as the caller last set it (see Voxel3D.tint)
pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
pcall(sh.send, sh, "fireflyNight", Voxel3D.fireflyNight or 0)
-- and the map's haze (see Voxel3D.fog), density 0 when there is none
local fog = Voxel3D.fog
pcall(sh.send, sh, "fogColor", (fog and fog.color) or { 0, 0, 0 })
@@ -1101,6 +1190,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).
@@ -1380,6 +1473,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)
+20
View File
@@ -934,6 +934,9 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
local GlassMask = V.require("GlassMask")
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
-- The existing day/night ramp is also a darkness factor. Fireflies fade
-- in naturally at dusk and reach full contrast only at deepest night.
Voxel3D.fireflyNight = outdoor and DayNight.windowLight() or 0
local g = VoxelScene.glintStep(glint, cx, cy)
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
-- and the map's atmosphere, if it has one (see ForestAtmos): the haze
@@ -1186,6 +1189,21 @@ 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
if ViewBox.showsMap(nb) then
@@ -1193,6 +1211,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
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