-- Voxel world mode: the 3D pass -- shader, depth buffer and camera. -- -- World space is world PIXELS, so every coordinate the 2D paths already -- compute drops straight in with no unit conversion: -- -- +X map east (world-pixel x) -- +Y up (0 is the ground plane) -- +Z map south (world-pixel y) -- -- A character at rest faces +Z, i.e. toward a camera parked to the south, -- which is what "facing down" means in the 2D game -- and a character card -- is drawn in exactly that pose, leaning back rather than yawing. -- -- The camera orbits the view centre at Voxel.angle: 0 is straight down -- (what the flat 2D view already is) and 50 degrees leans toward the -- horizon. Distance and field of view are tied to Voxel.FOCAL, which is the -- same constant Tilt projects with, so a given angle frames the world -- identically in both modes -- switching between them changes the geometry, -- not the framing. -- -- Every GPU object is pcall-guarded and `available()` reports the result: -- headless test runs and any driver without depth-canvas support fall back -- to the existing tilt/flat paths rather than erroring. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local Mat4 = V.require("Mat4") local Voxel = V.require("VoxelState") local ShadowMap = V.require("ShadowMap") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local Sky = V.require("Sky") local DayNight = V.require("DayNight") local GlassMask = V.require("GlassMask") local Voxel3D = {} -- Vertex format shared by terrain chunks and character models: a position, -- the map-canvas / sprite-sheet pixel it samples, and a per-vertex darken -- factor that gives a face its angle to the sun without a normal or a -- light uniform. Cast shadows are a separate thing entirely -- see -- ShadowMap, which the pixel shader below samples on top of this. Voxel3D.FORMAT = { { "VertexPosition", "float", 3 }, { "VertexTexCoord", "float", 2 }, { "VertexShade", "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 -- darkest. The sun hangs in the SOUTHEAST (see ShadowMap), so south and -- east are the lit flanks and north and west the shaded ones -- east and -- west used to share one value back when the sun sat due northwest and the -- two were symmetric about it. -- -- This is still worth baking even now that the shadow pass throws real -- shadows: a face turned away from the sun is dark because of its ANGLE, -- which no shadow map measures, and the two compound the way they should -- -- an away-facing wall that is also occluded goes darker still. Voxel3D.FACE_SHADE = { [1] = 0.84, -- +X east (toward the sun) [2] = 0.72, -- -X west (away) [3] = 1.00, -- +Y up [4] = 0.55, -- -Y down [5] = 0.90, -- +Z south (toward the camera, and toward the sun) [6] = 0.68, -- -Z north (away) } local SHADER = [[ varying float vShade; 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 // here in a way it does not for a colour: the seam is the FRACTIONAL // part of a coordinate that runs to a few thousand across a big route, // so a mediump varying would quantise the fraction away entirely. varying LOVE_HIGHP_OR_MEDIUMP vec3 vGrid; #endif #ifdef VERTEX uniform mat4 vp; uniform mat4 model; uniform mat4 sunModel; // where the SUN sees this vertex (see below) uniform mat4 sunVP; // world -> the shadow map's unit cube uniform vec3 eye; uniform float pull; uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off attribute float VertexShade; vec4 position(mat4 transform_projection, vec4 vertex_position) { 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 // posed rather than the world's grid sliding across a leaning sprite vGrid = vertex_position.xyz; #endif vec4 w = model * vertex_position; // The shadow lookup runs off `sunModel`, not `model`. For terrain the // two are the same matrix, but a character is drawn as a slab LEANING // back by the camera's pitch -- a trick played on the viewer, which // the sun never saw: it lit the upright card. Looking up with the // leaned position asks whether the sun reached a place the figure is // not, and since the lean tips the body north and shadows now fall // north, every sprite's own card fell across its front. Looking up // with the card's position asks the question the sun actually // 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; // 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 // both are worked out on the flat world and the bend carries them // along -- which is why neither has to know this exists. Along Y only, // so a column moves as one piece: the world tips away and the // buildings standing on it stay upright. if (curve.z > 0.0) { vec2 cd = w.xz - curve.xy; w.y -= dot(cd, cd) * curve.z; } // camera-ward pull: move the vertex along ITS OWN ray to the eye. // This is a pure depth bias -- the projection of a point moved along // its eye ray is bit-identical, so there is no screen drift at all. // (An earlier CPU version translated along the central view axis, // which preserved only the screen centre and made off-centre sprites // and grass swim against the ground while the camera scrolled.) if (pull > 0.0) { w.xyz += normalize(eye - w.xyz) * pull; } return vp * w; } #endif #ifdef PIXEL uniform Image sunMap; uniform float sunDark; // how far into black a shadow goes; 0 = off uniform float sunBias; uniform vec2 sunTexel; // the two-channel pack ShadowMap writes: high byte, then low float sunDepth(vec2 uv) { vec4 c = Texel(sunMap, uv); return c.r + c.g * (1.0 / 255.0); } // 1.0 in full sun, 1.0 - sunDark in full shadow. Four taps half a texel // out on the diagonals: a 2x2 box filter, which is what turns the // shadow map's texel staircase into a one-pixel soft edge. float sunlight(vec3 p) { if (sunDark <= 0.0) return 1.0; // outside the sun's frustum nothing was recorded, so nothing occludes if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) { return 1.0; } // Ease the shadows off at the frustum's rim. The map covers the ground // the camera can see out to a cap, and past the low rungs -- 75 degrees // especially -- the horizon is further than any box worth paying for. // Without this the covered region simply ENDS, drawing a hard line // across the middle distance where every shadow stops at once; with it // the far field just loses them, which reads as distance. vec2 e = min(p.xy, 1.0 - p.xy); float edge = smoothstep(0.0, 0.06, min(e.x, e.y)); if (edge <= 0.0) return 1.0; float z = p.z - sunBias; float lit = step(z, sunDepth(p.xy + sunTexel * vec2(-0.5, -0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, -0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2(-0.5, 0.5))) + step(z, sunDepth(p.xy + sunTexel * vec2( 0.5, 0.5))); return 1.0 - sunDark * edge * (1.0 - lit * 0.25); } #ifdef VOXEL_GRID uniform float gridDark; // how far toward black a seam pulls; 0 = off uniform float gridWidth; // seam width, in display pixels // How much of this fragment a voxel seam covers, 0 to 1. float voxelSeam(vec3 p) { // how much of `p` this fragment spans on screen, per axis: the // conversion from model units to display pixels, measured rather than // derived, so it holds under any camera pitch or zoom vec3 w = fwidth(p); vec3 d = abs(fract(p + 0.5) - 0.5); // distance to the nearest plane // The axis a face does not vary along is that face's own normal, and // its distance is a constant zero -- take it at face value and every // face floods solid. Push those axes out of reach instead of dividing // by their zero. vec3 live = step(1e-4, w); vec3 px = d / max(w, vec3(1e-6)) + (1.0 - live) * 1e6; float near = min(min(px.x, px.y), px.z); // Fade out where a voxel is too small to hold a line. Survey zoom // draws a world pixel at about a display pixel, and a wall seen nearly // edge-on squashes one to nothing at any zoom -- either way the seams // land closer together than they are wide, and drawn anyway they stop // being a wireframe and become a flat 45% dimming of the whole scene. // The tightest axis decides, which is the honest test of whether the // grid can be resolved at all. float span = 1.0 / max(max(w.x, max(w.y, w.z)), 1e-6); float fade = clamp((span - 2.0) * 0.5, 0.0, 1.0); // the textbook antialiased line: solid within the half-width, fading // over the one pixel outside it return fade * clamp(gridWidth * 0.5 + 0.5 - near, 0.0, 1.0); } #endif 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 uniform Image glassMask; // opaque where the atlas texel is window glass uniform vec2 glassSize; // the mask's dimensions: tc -> atlas texels uniform float glassNight; // 0 = daylight .. 1 = the lamps are on uniform float glassPhase; // the glint's phase: advances with TRAVEL uniform float glassGlint; // and its strength: 0 while standing still uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass) vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 p = Texel(tex, tc); // sprite sheets key GB OBJ color 0 to alpha 0; discarding rather than // blending keeps those texels out of the depth buffer, so a model never // carves a transparent hole out of whatever stands behind it 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; #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 rgb *= 1.0 - gridDark * voxelSeam(vGrid); #endif // WINDOW GLASS, marked per atlas texel by the mask (see GlassMask). // By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES // -- the phase is fed by the camera's own travel and the strength dies // within a beat of standing still, because a reflection is something // the viewpoint does: still camera, still glass. It lifts the texel // toward sky-white and leaves the art visible through it. After dark // the pane is LIT: the texel's own shine pattern carried into a warm // lamp colour, replacing the shaded answer above -- so a lit window // ignores the sun, every shadow and the hour's tint, exactly as a // window with a lamp behind it does. // glassOn gates the whole thing per DRAW: the mask is shaped like the // tileset atlas, and only meshes textured FROM that atlas may consult // it -- a character samples its own sprite sheet, whose coordinates // land on the mask's pane rectangles by accident and would stripe the // cast with lamplight at night. float glass = Texel(glassMask, tc).a * glassOn; if (glass > 0.0) { // the sweep lives in the PANE's own space (atlas texels), not the // screen's: a pattern anchored to the screen has the world sliding // through it at zoom speed whenever the camera pans, which strobed -- // worst where the pan and the phase ran opposite ways. Anchored to // the glass, panning moves nothing; only the phase does, a fraction // of a texel per step, the same in every walking direction. float sweep = sin(tc.x * glassSize.x * 0.8 - glassPhase); float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint; vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass); float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114)); vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine); rgb = mix(pane, lamp, glassNight * glass); } // The hidden player is a SHAPE, not a dimmed picture of itself. Tinting // through `color` could only multiply the sprite's own pixels, which // darkens each one by its own amount and keeps the character's internal // detail; replacing the colour outright is what makes it read as one // solid silhouette. Last in the chain, so neither the sun nor a voxel // seam can mottle it. rgb = mix(rgb, ghostColor, ghost); return vec4(rgb, 1.0) * color; } #endif ]] -- Two compilations of SHADER: the plain scene, and the same thing with the -- voxel wireframe compiled in. The wireframe needs shader derivatives -- (fwidth), the one piece of this a driver can refuse, so it is a separate -- build rather than a branch -- a refusal costs the grid and nothing else. -- Each entry is nil = untried, false = unavailable. local shaders = { [false] = nil, [true] = nil } local activeShader = nil -- the variant this pass bound -- Scene canvases, one per NAMED SLOT. There are exactly two callers and -- they want different sizes -- the free-roam pass renders at the window's -- pixel dimensions, the overworld battle at the GB's 160x144 -- and a -- single cached canvas made every battle entry and exit reallocate one. -- A slot reallocates only when its OWN size changes, which is a window -- resize, so the pair is stable for a session. local slots = {} local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound local active = false local IDENTITY = Mat4.identity() -- Whether the driver admits to supporting derivatives. Only a hint -- -- the compile below is the real test -- but it saves building a shader -- that was never going to work, and it is how LOVE reports the ES2 -- extension the grid rides on. local function derivativesOK() if not (love.graphics and love.graphics.getSupported) then return false end local ok, caps = pcall(love.graphics.getSupported) return ok and caps and caps.shaderderivatives == true end -- The scene shader. `grid` asks for the wireframe variant, and nil comes -- back when that one will not build -- callers then fall back to the plain -- one rather than losing the whole 3D pass. function Voxel3D.shader(grid) grid = grid and true or false if shaders[grid] == nil then if grid and not derivativesOK() then shaders[grid] = false else local src = grid and ("#define VOXEL_GRID 1\n" .. SHADER) or SHADER local ok, sh = pcall(love.graphics.newShader, src) shaders[grid] = ok and sh or false end end return shaders[grid] or nil end -- Whether the 3D path can run at all. False on a headless test run (no -- love.graphics), without shader support, or where a depth canvas cannot be -- created -- every caller treats that as "stay on the 2D path". function Voxel3D.available() if not (love.graphics and love.graphics.newCanvas and love.graphics.setDepthMode) then return false end return Voxel3D.shader() ~= nil end -- Build a mesh in the shared format. `verts` is the LOVE vertex list and -- `map` the triangle index list. Returns nil when meshes are unavailable, -- which the callers treat the same way they treat a missing model. function Voxel3D.newMesh(verts, map) if #verts == 0 then return nil end local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.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. Voxel3D.FACE_CORNERS = { [1] = { { 1, 0, 0 }, { 1, 0, 1 }, { 1, 1, 1 }, { 1, 1, 0 } }, -- +X [2] = { { 0, 0, 1 }, { 0, 0, 0 }, { 0, 1, 0 }, { 0, 1, 1 } }, -- -X [3] = { { 0, 1, 0 }, { 1, 1, 0 }, { 1, 1, 1 }, { 0, 1, 1 } }, -- +Y [4] = { { 0, 0, 1 }, { 1, 0, 1 }, { 1, 0, 0 }, { 0, 0, 0 } }, -- -Y [5] = { { 0, 0, 1 }, { 1, 0, 1 }, { 1, 1, 1 }, { 0, 1, 1 } }, -- +Z [6] = { { 1, 0, 0 }, { 0, 0, 0 }, { 0, 1, 0 }, { 1, 1, 0 } }, -- -Z } -- Append the six indices of quad `n` (0-based) to a triangle index list. function Voxel3D.pushQuad(map, n) local b = n * 4 map[#map + 1] = b + 1 map[#map + 1] = b + 2 map[#map + 1] = b + 3 map[#map + 1] = b + 1 map[#map + 1] = b + 3 map[#map + 1] = b + 4 end -- ---------------------------------------------------------------- camera -- -- An explicit camera, replacing the orbit below for as long as it is set: -- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }. -- -- The orbit is the free-roam camera and it is described entirely by ONE -- number, the pitch, because that is all a camera following the player over -- their own map ever needs. A staged shot -- the overworld battle's -- over-the-shoulder rig (see BattleCam) -- is a placed camera: it has a yaw, -- it does not sit above its focus, and its framing comes from the arena -- rather than from the view size. Rather than widen the orbit into -- something that could express both and be the wrong shape for each, a -- caller with a camera of its own simply hands it over. -- -- Everything downstream is unchanged by this: the shader uniforms, project() -- and the overlay all read Voxel3D.vp / Voxel3D.eye, which are set the same -- way either way. Voxel3D.camera = nil -- View and projection for a `vw` x `vh` world-pixel view centred on -- (cx, cy) in world pixels. Returns the combined matrix. function Voxel3D.viewProjection(cx, cy, vw, vh) local cam = Voxel3D.camera if cam then local eye, focus = cam.eye, cam.focus Voxel3D.eye = eye -- kept beside the eye for horizonY: where the sky's pale end goes is a -- question about which way this camera looks, and only these two answer it Voxel3D.focus = focus local dx = eye[1] - focus[1] local dy = eye[2] - focus[2] local dz = eye[3] - focus[3] local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz)) local proj = Mat4.perspective(cam.fov, vw / vh, math.max(1, dist * 0.05), dist * 4 + 4096) -- the same clip-space Y flip the orbit needs, for the same reason: we -- bypass LOVE's transform_projection and canvas coordinates run Y down proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) -- world up, so the horizon stays level -- a placed camera that rolled -- with its own pitch would tip the whole arena return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 })) end local a = Voxel.angle local focal = Voxel.FOCAL local dist = focal * vh -- the FOV that makes a straight-down camera at `dist` frame exactly `vh` -- world pixels, which is the framing the flat view already has local fov = 2 * math.atan(1 / (2 * focal)) local focus = { cx, 0, cy } local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) } -- exposed for camera-facing billboards (VoxelScene yaws sprites at it) Voxel3D.eye = eye Voxel3D.focus = focus -- perpendicular to the view direction in the YZ plane: north is screen-up -- when looking straight down, +Y is screen-up when looking level. Never -- parallel to the view direction, so there is no degenerate a = 0 case. local up = { 0, math.sin(a), -math.cos(a) } local proj = Mat4.perspective(fov, vw / vh, math.max(1, dist * 0.05), dist * 4 + 4096) -- Flip clip-space Y. Mat4.perspective emits textbook GL clip space with -- +Y up, but we bypass LOVE's own transform_projection, and LOVE's canvas -- coordinates run Y DOWN -- so without this the entire scene composites -- vertically mirrored: north at the bottom and buildings extruding -- downward. Winding flips with it, which is free here because the pass -- draws with culling off. proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) return Mat4.mul(proj, Mat4.lookAt(eye, focus, up)) end -- ------- the horizon -- -- Where the ground plane's vanishing line lands, in canvas pixels down from the -- top edge, or nil when this camera has no horizon to find. -- -- Not a fraction picked by eye. A direction ALONG the ground is a point at -- infinity, and putting one through the same matrix the geometry is drawn with -- gives the line every ground plane in the scene converges on -- so the sky's -- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides -- the camera tween instead of having to be retuned against it. -- -- The world CURVE is not in it, and cannot be: it bends distant ground down in -- the vertex shader, so the ground's apparent edge sits BELOW this line by -- however much the bend took. What shows in between is the haze the sky's fill -- already is, which is what a curved-away horizon should look like. -- -- nil in two cases, both meaning "no horizon in this frame": a camera looking -- straight down, whose forward direction has no horizontal part to send to -- infinity, and one whose vanishing line is behind it. function Voxel3D.horizonY(h) local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus if not (m and eye and focus and h and h > 0) then return nil end local dx = focus[1] - eye[1] local dz = focus[3] - eye[3] local len = math.sqrt(dx * dx + dz * dz) if len < 1e-6 then return nil end dx, dz = dx / len, dz / len -- a DIRECTION, so its w is zero and the matrix's translation column drops -- out; the clip-space Y flip is already baked into m, so this comes out in -- canvas coordinates rather than needing one local y = m[5] * dx + m[7] * dz local w = m[13] * dx + m[15] * dz if w <= 1e-6 then return nil end return (y / w * 0.5 + 0.5) * h end -- ------- the hour's light -- -- What the scene shader multiplies every surface by (see dayTint in the -- shader). Set per pass by whoever knows what map is being drawn -- -- VoxelScene for free-roam, BattleScene for the arena -- because "is this -- outdoors" is the map's question, not this pass's. Neutral until somebody -- answers it, so a caller that never does draws exactly what it always drew. Voxel3D.tint = { 1, 1, 1 } -- The window-glass pass, set the same way and for the same reason: the -- MASK belongs to the map's tileset (GlassMask.texture) and how lit the -- panes are belongs to the hour and to being outdoors at all -- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect. Voxel3D.glassMask = nil Voxel3D.glassNight = 0 -- the glint, fed by the camera's TRAVEL rather than by a clock (see -- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and -- the strength is 0 whenever the view has been still for a beat Voxel3D.glassPhase = 0 Voxel3D.glassGlint = 0 -- The sun or moon disc's place on this camera's canvas, or nil when the -- body is set, on the southern half of the sky, or behind the camera. -- -- The direction comes from DayNight (true bearing, squashed elevation) and -- goes through the SAME matrix the geometry is drawn with, as a point at -- infinity -- exactly how horizonY finds the vanishing line. So the disc's -- azimuth is honest: it stands over the point on the horizon its shadows -- point away from, at every pitch, fov, window shape and zoom. -- -- Must run after beginScene has set Voxel3D.vp for this frame's camera. function Voxel3D.skyBody(w, h) local m = Voxel3D.vp local b = m and DayNight.body() if not b then return nil end local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz if ww <= 1e-6 then return nil end local amt, color = DayNight.glow() return { x = (x / ww * 0.5 + 0.5) * w, y = (y / ww * 0.5 + 0.5) * h, moon = b.moon, glowAmt = amt, glowColor = color, } end -- ----------------------------------------------------------------- scene -- -- Begin the 3D pass into a `w` x `h` pixel canvas centred on world -- (cx, cy), covering `vw` x `vh` world pixels. Returns false when the pass -- could not start, in which case the caller must not call endScene. -- `sky` is an optional {r, g, b, a} in 0..1 to clear the void to, for the -- pitch where the horizon is in frame (VoxelScene.skyFor). nil leaves the -- void transparent, which is what every rung below it wants. -- `slot` names which cached canvas to render into (see `slots` above); -- omitted is the free-roam world pass. function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- the wireframe variant when the player has it on AND it built; either -- answer falls through to the plain scene rather than to no scene local grid = VoxelGrid.enabled() local sh = grid and Voxel3D.shader(true) or nil if not sh then grid, sh = false, Voxel3D.shader() end if not sh then return false end local name = slot or "world" local held = slots[name] if not (held and held.w == w and held.h == h) then local ok, c = pcall(love.graphics.newCanvas, w, h) if not ok then return false end c:setFilter("nearest", "nearest") if held and held.canvas and held.canvas.release then pcall(held.canvas.release, held.canvas) end held = { canvas = c, w = w, h = h } slots[name] = held end canvas, canvasW, canvasH = held.canvas, w, h -- a depth buffer is what makes occlusion real: walk behind a building and -- the building wins, with no y-sorting anywhere local ok = pcall(love.graphics.setCanvas, { canvas, depth = true }) if not ok then pcall(love.graphics.setCanvas) return false end -- Ahead of the clear, because the sky's bands are placed off the ground -- plane's vanishing line and that is a property of this matrix. Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh) if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a -- rectangle is just a rectangle: the depth mode and the scene shader are -- both set below. Sky.paint puts them aside anyway -- beginScene is not the -- only thing that has ever left a shader bound. -- -- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama -- pixel is on screen: the sky's dither grid is cut to that, so its squares -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w), sky.bands and Voxel3D.skyBody(w, h) or nil) else love.graphics.clear(0, 0, 0, 0, true, true) end love.graphics.setDepthMode("lequal", true) -- models mirror on X for right-facing and alternate walk steps, which -- flips winding; hidden faces are already culled at build time, so there -- is nothing to gain from backface culling and a real bug to avoid love.graphics.setMeshCullMode("none") love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) pcall(sh.send, sh, "vp", "row", Voxel3D.vp) pcall(sh.send, sh, "eye", Voxel3D.eye) -- the sun's frame, filled by ShadowMap just before this pass opened. -- Sent unconditionally: the sampler is declared either way, and leaving -- one unbound is a driver-dependent crash rather than a fallback. local map = ShadowMap.active() pcall(sh.send, sh, "sunVP", "row", map and ShadowMap.uvVP or IDENTITY) 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) pcall(sh.send, sh, "sunBias", ShadowMap.bias) local texel = 1 / ShadowMap.res pcall(sh.send, sh, "sunTexel", { texel, texel }) if grid then pcall(sh.send, sh, "gridDark", VoxelGrid.DARK) pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH) end -- ordinary shading until the silhouette pass asks for otherwise. Sent -- every frame rather than once, because a scene that opened mid-ghost -- -- a driver hiccup between beginGhost and endGhost -- would otherwise -- start out flattening everything it drew. pcall(sh.send, sh, "ghost", 0) 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 }) -- the window glass: the tileset's mask (or the blank -- the sampler is -- declared either way, and unbound is a driver-dependent crash), how lit -- the panes are, and the movement-fed glint as the caller last set it local mask = Voxel3D.glassMask or GlassMask.blank() if mask then pcall(sh.send, sh, "glassMask", mask) local ok, mw, mh = pcall(mask.getDimensions, mask) pcall(sh.send, sh, "glassSize", { ok and mw or 1, ok and mh or 1 }) end pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0) pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0) 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) -- 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). local placed = Voxel3D.camera Voxel3D.curveK = (placed and placed.curve) or WorldCurve.k(vh) Voxel3D.curveX, Voxel3D.curveZ = cx, cy pcall(sh.send, sh, "curve", { cx, cy, Voxel3D.curveK }) -- clip w at the focus point, the reference depth project() reports scale -- against (so scale == 1 for anything standing at the view centre) local m = Voxel3D.vp Voxel3D.focusW = m[13] * cx + m[14] * 0 + m[15] * cy + m[16] activeShader = sh active = true return true end -- Depth handling for the character pass. Gen 1 draws sprites over the -- background unconditionally, so characters render with the depth test -- forced to pass (still writing depth: the grass mesh drawn after them -- tests against it to overdraw feet). "test" restores normal occlusion. function Voxel3D.depth(mode) if not active then return end pcall(love.graphics.setDepthMode, mode == "always" and "always" or "lequal", true) end -- ------------------------------------------------ the player's own ghost -- -- The silhouette's colour, and how solid it is. -- -- ONE flat grey rather than a dimmed copy of the sprite, so the shape reads -- at a glance instead of competing with whatever is showing through it -- -- and translucent rather than opaque, so it stays a hint of where the -- player is rather than a hole punched in the building. The wall it is -- seen through still shows, which is what keeps it reading as "behind -- that" instead of "in front of it". Voxel3D.GHOST_COLOR = { 0.26, 0.26, 0.28 } Voxel3D.GHOST_ALPHA = 0.5 -- Draw a character AGAIN wherever the ordinary draw LOST the depth test. -- -- Honest occlusion is the point of this mode -- walk behind the Mart and the -- Mart is genuinely in front of you -- but a player who cannot see their own -- character has lost track of where they are standing, which the flat game -- never allowed. So the figure is drawn a second time with the test -- INVERTED: "greater" passes exactly where "lequal" failed, and LOVE hands -- the compare straight to glDepthFunc, so the two are true complements. -- Every texel of the sprite is therefore drawn once and once only -- solid -- where it is visible, translucent where it is not -- with no seam where -- they meet and no double-blending anywhere. -- -- Nothing is drawn at all when nothing is in the way, and no code here ever -- asks whether the player is occluded: the depth buffer already knows, and -- the test is the question. -- -- Depth WRITES are off. This pass is behind the scenery by definition, and -- writing would file the hidden figure's depth in front of the building -- hiding it -- the grass pass at the end of the frame reads that buffer. -- -- The caller redraws through the ordinary character path, so the ghost keeps -- the same mesh, matrix and camera-ward PULL as the real draw. The pull -- matching is what keeps the leaning-over-a-near-wall case out of here: pull -- already won that fight for the solid draw, so this pass finds nothing left -- to paint and a character merely standing close to a wall does not shimmer -- a ghost over it. function Voxel3D.beginGhost() if not active then return end pcall(love.graphics.setDepthMode, "greater", false) love.graphics.setColor(1, 1, 1, Voxel3D.GHOST_ALPHA) if activeShader then pcall(activeShader.send, activeShader, "ghostColor", Voxel3D.GHOST_COLOR) pcall(activeShader.send, activeShader, "ghost", 1) end end -- Flatten whatever is drawn next to one solid colour, or nil to stop. -- -- The same `ghost` path the silhouette uses, WITHOUT beginGhost's inverted -- depth test and half alpha -- this is for something drawn normally that -- simply wants to come out one colour, which is what a hit flash on a sprite -- is. beginScene resets the uniform every frame, so a pass that forgets to -- clear it cannot leak into the next one. -- `amount` is how far toward that colour, 0..1; omitted is all the way. -- Anything short of 1 leaves the sprite's own shading showing through, which -- is the difference between a hit flash and a white cut-out. function Voxel3D.flatten(color, amount) if not (active and activeShader) then return end local sh = activeShader if color then pcall(sh.send, sh, "ghostColor", color) pcall(sh.send, sh, "ghost", math.max(0, math.min(1, amount or 1))) else pcall(sh.send, sh, "ghost", 0) end end -- Whether what is drawn next carries the voxel wireframe. false for the -- length of a draw, true to put it back. -- -- The wireframe reads a mesh's OWN model space and darkens its integer -- planes (see VoxelGrid), which is only a wireframe because every mesh in -- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a -- character card in the sprite's own pixels. A mesh whose model space does -- not mean that gets no wireframe out of the same shader -- it gets -- whichever of its integer planes happen to fall inside it, which is a -- stray line rather than a seam. -- -- So this is not a style switch. It is how a mesh that is not on the voxel -- grid says so, and the alternative -- rescaling such a mesh until its -- units happen to be voxels -- would change what it IS to satisfy a -- shading pass. -- -- Sent rather than branched because the plain scene shader has no such -- uniform, and the send simply does not take there -- which is right: with -- no wireframe compiled in there is nothing to suppress. function Voxel3D.seams(on) if not (active and activeShader) then return end pcall(activeShader.send, activeShader, "gridDark", on and VoxelGrid.DARK or 0) end -- Whether what is drawn next may consult the glass mask. false for the -- length of a sprite-sheet pass, true to put it back. -- -- Same shape as seams(), for the same reason: the mask means "this ATLAS -- texel is window glass", so it is only an answer for meshes textured from -- the tileset atlas. A sprite sheet's coordinates land wherever they land -- on it, and at night that painted lamplight stripes down whoever was -- standing in the wrong part of their own sheet. function Voxel3D.glass(on) if not (active and activeShader) then return end pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0) end function Voxel3D.endGhost() if not active then return end pcall(love.graphics.setDepthMode, "lequal", true) love.graphics.setColor(1, 1, 1, 1) -- back to ordinary shading before anything else draws; leaving it set -- would flatten the grass pass that follows into one grey sheet if activeShader then pcall(activeShader.send, activeShader, "ghost", 0) end end -- -------------------------------------------------------------- shadows -- -- The sun. One direction, shared by everything that needs to know where -- the light comes from: the shadow map, the flat fallback below, and the -- baked contact shading in ChunkMesher. Both shears are negative, which -- hangs it in the SOUTHEAST and throws every shadow northwest -- up and to -- the left on screen. Voxel3D.SHADOW_KX = ShadowMap.KX -- west drift per pixel of height Voxel3D.SHADOW_KZ = ShadowMap.KZ -- north drift per pixel of height Voxel3D.SHADOW_EPS = 0.25 -- float above the ground to dodge z-fighting Voxel3D.SHADOW_ALPHA = 0.40 -- how far into black a shadowed surface goes -- Whether real shadows are running this frame. False headless and on any -- driver the sun pass could not start on, which is when VoxelScene falls -- back to the flat decals below. function Voxel3D.shadowsActive() return ShadowMap.active() end -- The upright card a character presents to the sun: its 16x16 sprite quad -- (corners (0,0,0)..(16,16,0), feet at y = 0) standing on the middle of -- the cell whose top-left is world (px, py), feet at height `y`. -- -- This is the caster the shadow pass draws -- deliberately NOT the leaning -- slab the camera sees. The slab tips back by the camera's pitch to read -- face-on, which is a trick played on the viewer; letting the sun see it -- too would shrink every shadow to nothing as the camera flattened toward -- top-down. The sun sees the figure standing up, at every tilt. -- -- The z-flatten matters when this is used the other way round, as the -- lookup transform a lit slab reads its own shadowing with (Voxel3D.draw's -- `sunModel`): it collapses the slab's side relief onto the card plane, so -- every vertex asks about the exact surface the sun recorded rather than -- one a few pixels behind it, and a figure cannot fringe itself. On the -- 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)) end -- FALLBACK ONLY (no shadow map: headless, or a driver that cannot make the -- canvas). Character drop shadows as decals -- the sprite frame squashed -- flat onto its ground plane and drawn translucent black. It can only ever -- paint the floor, which is the whole reason ShadowMap exists. -- -- Flattening is measured from the ground plane, so a hop slides the whole -- shadow along the sun line while it stays glued to the ground -- the -- classic jump-shadow tell. function Voxel3D.shadowMatrix(px, py, gh, lift, mirror) local card = Voxel3D.casterMatrix(px, py, gh + (lift or 0), mirror) -- flatten about the ground plane: y' = 0, x/z shear by height above it local squash = { 1, Voxel3D.SHADOW_KX, 0, 0, 0, 0, 0, 0, 0, Voxel3D.SHADOW_KZ, 1, 0, 0, 0, 0, 1 } local m = Mat4.mul(squash, Mat4.mul(Mat4.translate(0, -gh, 0), card)) return Mat4.mul(Mat4.translate(0, gh + Voxel3D.SHADOW_EPS, 0), m) end -- The decal pass draws between terrain and characters: depth-tested so a -- building still hides a shadow behind it, but NOT depth-writing -- the -- grass tufts drawn at the end of the frame must keep beating the ground -- plane, and one quad per entity has no self-overlap to guard against. function Voxel3D.beginShadows() if not active then return end pcall(love.graphics.setDepthMode, "lequal", false) love.graphics.setColor(0, 0, 0, Voxel3D.SHADOW_ALPHA) end function Voxel3D.endShadows() if not active then return end pcall(love.graphics.setDepthMode, "lequal", true) love.graphics.setColor(1, 1, 1, 1) end -- Draw one mesh with `model` (a Mat4) applied. Texture may be nil to keep -- whatever the mesh already carries. `pull` moves every vertex toward the -- eye along its own ray (see the shader) -- the artifact-free depth bias -- the character and grass passes ride in front of the terrain. -- -- `sunModel` is where the SHADOW PASS put this same geometry, and defaults -- to `model` because for everything but a character the two are one matrix. -- A character is drawn leaning and cast upright, so it must hand over the -- upright transform or it reads its own shadow as falling on itself. function Voxel3D.draw(mesh, texture, model, pull, sunModel) if not (active and mesh) then return end -- the variant beginScene actually bound, not whichever one is default: -- sending a uniform to the other shader would go nowhere local sh = activeShader if not sh then return end if texture then mesh:setTexture(texture) end -- LOVE defaults matrix uniforms to column-major; Mat4 is row-major pcall(sh.send, sh, "model", "row", model or IDENTITY) pcall(sh.send, sh, "sunModel", "row", sunModel or model or IDENTITY) pcall(sh.send, sh, "pull", pull or 0) love.graphics.draw(mesh) end -- Project a world point to canvas pixels: returns (x, y, scale), or nil -- when the point is behind the camera. `scale` is how much bigger a thing -- at that depth appears than one at the focus point, so a caller can size -- with it -- or ignore it and draw unscaled, which is what tilt mode's -- billboards do. -- -- This is what lets the overworld's FX closures (the "!" bubble, the heal -- machine, the Fly bird, the fishing rod) draw in voxel mode completely -- unchanged: they stay ordinary 2D draws, anchored to wherever their ground -- point lands under the same camera the 3D pass used. function Voxel3D.project(wx, wy, wz) local m = Voxel3D.vp if not m then return nil end -- the same drop the vertex shader applies, or every FX anchored to a -- ground point floats off its own feet the moment that ground bends wy = wy - WorldCurve.drop(Voxel3D.curveK or 0, Voxel3D.curveX or 0, Voxel3D.curveZ or 0, wx, wz) local cx = m[1] * wx + m[2] * wy + m[3] * wz + m[4] local cy = m[5] * wx + m[6] * wy + m[7] * wz + m[8] local cw = m[13] * wx + m[14] * wy + m[15] * wz + m[16] if cw <= 1e-6 then return nil end -- viewProjection already flipped clip-space Y into LOVE's Y-down canvas -- convention, so both axes map the same way here -- no second flip local x = (cx / cw * 0.5 + 0.5) * canvasW local y = (cy / cw * 0.5 + 0.5) * canvasH return x, y, (Voxel3D.focusW or cw) / cw end -- Re-bind the scene canvas for ordinary 2D drawing (no depth test), so -- screen-space overlays can be composited into the same image the 3D pass -- just filled. Pairs with endScene, which unbinds it. function Voxel3D.beginOverlay() if not canvas then return false end love.graphics.setShader() love.graphics.setDepthMode() local ok = pcall(love.graphics.setCanvas, canvas) if not ok then return false end love.graphics.setColor(1, 1, 1, 1) return true end -- Close the overlay begun by beginOverlay. function Voxel3D.endOverlay() love.graphics.setCanvas() active, activeShader = false, nil end -- End the pass and hand back the rendered canvas. function Voxel3D.endScene() if not active then return nil end love.graphics.setShader() love.graphics.setDepthMode() love.graphics.setMeshCullMode("none") love.graphics.setCanvas() active, activeShader = false, nil return canvas end function Voxel3D.canvas() return canvas end -- Drop the GPU objects (window resize, hot reload). function Voxel3D.invalidate() for name, held in pairs(slots) do if held.canvas and held.canvas.release then pcall(held.canvas.release, held.canvas) end slots[name] = nil end canvas, canvasW, canvasH = nil, 0, 0 ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() -- and the glass masks are textures of this context too GlassMask.invalidate() end return Voxel3D