mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 07:10:51 +02:00
update sky rendering, select button changes views
This commit is contained in:
+25
-12
@@ -1,9 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.0
|
||||
## 1.5.2
|
||||
|
||||
### Added
|
||||
|
||||
- **SELECT walks the VOXEL ladder.** In free roam, the pad's SELECT
|
||||
button makes exactly the step hotkey 3 makes -- OFF through the angle
|
||||
rungs to 1ST and round, stepping over FULL, clearing TILT and GBC FX
|
||||
on every press like the key does. For the machines with no number row:
|
||||
the phone's touch pad and a controller. SELECT has no overworld job in
|
||||
Gen 1 -- its work is all in-menu, and menus keep it untouched.
|
||||
|
||||
- **PCVR, in-process, through OpenXR -- with no change to the parent
|
||||
app.** A new **VR** row (OFF / ON, off by default; on the OPTIONS menu
|
||||
and the mod manager's page). The whole stack rides LuaJIT's FFI from
|
||||
@@ -90,17 +97,23 @@
|
||||
world's real horizon and drop the fixed-slice fallback entirely, so
|
||||
tilting your head slides the frame across a sky that stays put --
|
||||
and the discs, already projected through each eye's true camera,
|
||||
stand still over their own azimuth. The horizon holds LEVEL under a
|
||||
rolled head too: the whole painting runs along the horizon's own
|
||||
projected axis rather than the canvas's rows, so tipping your head
|
||||
tips the frame across the horizon instead of hinging the horizon
|
||||
with your ears -- and the discs' cell art is drawn in that same
|
||||
frame, so a tipped head no longer spins the moon's face in place.
|
||||
Under PITCH the gradient stays put as well: the band span's far end
|
||||
is the 55-degree direction's own projection through the eye's
|
||||
frustum rather than a pixels-per-radian estimate -- a perspective's
|
||||
rows are tan-spaced, not angle-spaced, and the linear guess let the
|
||||
bands slide as the horizon crossed the frame.
|
||||
stand still over their own azimuth. The gradient is a true SKYBOX:
|
||||
each eye hands the sky shader its own ray fan (head rotation plus
|
||||
frustum tangents), and every pixel takes its band -- and its GBC
|
||||
checker dither -- from the TRUE elevation of its own view ray, so
|
||||
no motion of the head, pitch, yaw or roll, in the diorama or in
|
||||
first person, moves a band by a pixel; only the clock recolours
|
||||
them. The FLAT screen's first person gets the very same treatment:
|
||||
the placed rig builds its own ray fan from the basis its view is
|
||||
made of, so mouse-look pitch slides the frame over a sky that
|
||||
stays put there too, dither and all -- while the orbit rungs keep
|
||||
their classic frame-hung painting. And the SUN AND MOON are no
|
||||
longer painted on the frame at all (in first person on the flat
|
||||
screen included): the cell art is baked to a texture once per
|
||||
palette and hung on a quad IN THE WORLD, projected through the
|
||||
camera like any geometry -- no per-frame cell snapping (the
|
||||
jitter), no pattern squared to the canvas (the face that turned
|
||||
with the head).
|
||||
|
||||
- **The VR row exists only where VR can.** Off Windows -- the Android
|
||||
build above all -- the row is absent from the OPTIONS menu and the
|
||||
|
||||
@@ -12,7 +12,8 @@ menu.
|
||||
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) |
|
||||
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) |
|
||||
| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row |
|
||||
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
|
||||
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
|
||||
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
|
||||
@@ -25,13 +26,7 @@ menu.
|
||||
## VR
|
||||
|
||||
The **VR** options row (OFF / ON, off by default) drives a PCVR headset
|
||||
through OpenXR on Windows — SteamVR, Oculus or WMR. On the orbit rungs
|
||||
the world is a head-tracked tabletop diorama at the rung's own angle; on
|
||||
**1ST** you stand inside it at life size; a battle snaps you (through a
|
||||
fade) into the game's own over-the-shoulder shot. Menus float on a
|
||||
panel wearing the Game Boy frame, and in first person they show up on
|
||||
the **Pokédex in your left hand** instead. Turning VR off is the VR
|
||||
row's job — no controller button does it.
|
||||
through OpenXR on Windows — SteamVR, Oculus or WMR.
|
||||
|
||||
### VR controls
|
||||
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
-- VR: the POKEDEX in the player's left hand -- a voxel model of the
|
||||
-- series' own field guide, strapped to the tracked grip pose, whose
|
||||
-- screen is a real texture the mod can put a picture on.
|
||||
--
|
||||
-- Why it exists: a staged VR battle needs the 2D battle screen SOMEWHERE
|
||||
-- -- the text, the menus, the HP bars are the game -- but a flat panel
|
||||
-- floating square in front of the fight hides the fight. A trainer in
|
||||
-- the world already has the right prop for "a handheld device with a
|
||||
-- screen": look down at the Pokedex in your hand to read the battle,
|
||||
-- look up to watch it happen on the map.
|
||||
--
|
||||
-- THE MODEL is authored here in voxels, in METRES (VOX metres a voxel),
|
||||
-- around its own centre, front face +Z -- a red slab with the lens, the
|
||||
-- LEDs, the hinge and a d-pad, and a dark bezel the screen sits proud
|
||||
-- of. It rides VRRig.propMatrix, the same XR-to-world mapping the eyes
|
||||
-- use, so it sits exactly where the hand is and keeps its real size in
|
||||
-- every mode: a hand-sized device over the diorama, the same hand-sized
|
||||
-- device at life scale in first person and in battle.
|
||||
--
|
||||
-- THE SCREEN is a separate one-quad mesh drawn with its own texture --
|
||||
-- whatever canvas the caller hands `Pokedex.screen` (the VR frame hands
|
||||
-- it the front buffer during a battle, cropped by UV to the battle's own
|
||||
-- letterbox). No texture leaves the screen dark: a device that is off.
|
||||
--
|
||||
-- Everything here is passive state plus a draw call; VR.lua decides when
|
||||
-- the frame exists (hand tracked, session live) and VoxelScene's eye
|
||||
-- pass draws it after the world, so it composites with real depth
|
||||
-- against everything else.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local VRRig = V.require("VRRig")
|
||||
|
||||
local Pokedex = {}
|
||||
|
||||
-- one voxel, in metres: a centimetre-ish grid gives the classic chunky
|
||||
-- read at a believable device size (the body below comes out about
|
||||
-- 10 x 15 x 2.5 cm)
|
||||
Pokedex.VOX = 0.011
|
||||
|
||||
-- Where the device sits relative to the GRIP pose, in metres, and how it
|
||||
-- is tipped. A full quarter turn forward lays the slab exactly along the
|
||||
-- controller's own body -- verified in the headset -- so holding the
|
||||
-- controller IS holding the device: raise your fist and the screen faces
|
||||
-- you. These two are the whole of the attachment.
|
||||
Pokedex.OFFSET = { 0, 0.04, -0.02 }
|
||||
Pokedex.TILT = -math.pi / 2 -- radians about X: 90 degrees forward,
|
||||
-- flush with the controller
|
||||
|
||||
-- body proportions, in voxels
|
||||
local W, H, D = 9, 14, 2
|
||||
|
||||
-- the palette the body's faces point their UVs at, one texel per colour
|
||||
local COLORS = {
|
||||
{ 200, 40, 48 }, -- 1 body red
|
||||
{ 140, 24, 32 }, -- 2 hinge / shaded red
|
||||
{ 64, 132, 244 }, -- 3 the lens blue
|
||||
{ 208, 228, 255 }, -- 4 lens glint
|
||||
{ 232, 60, 48 }, -- 5 LED red
|
||||
{ 248, 216, 64 }, -- 6 LED yellow
|
||||
{ 72, 200, 96 }, -- 7 LED green
|
||||
{ 46, 46, 54 }, -- 8 bezel / d-pad dark
|
||||
{ 24, 24, 30 }, -- 9 the dark screen (the "off" state's face)
|
||||
}
|
||||
|
||||
local paletteTex = nil -- one texel per COLORS entry
|
||||
local bodyMesh = nil
|
||||
local screenMesh = nil
|
||||
local screenKey = nil -- the UV rect screenMesh was built for
|
||||
|
||||
local function palette()
|
||||
if paletteTex then return paletteTex end
|
||||
if not (love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then return nil end
|
||||
local ok, data = pcall(love.image.newImageData, #COLORS, 1)
|
||||
if not (ok and data) then return nil end
|
||||
for i, c in ipairs(COLORS) do
|
||||
pcall(data.setPixel, data, i - 1, 0,
|
||||
c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
end
|
||||
local built, img = pcall(love.graphics.newImage, data)
|
||||
if not built then return nil end
|
||||
pcall(img.setFilter, img, "nearest", "nearest")
|
||||
paletteTex = img
|
||||
return img
|
||||
end
|
||||
|
||||
-- Append one solid box's six faces to `verts`/`indices`: position in
|
||||
-- voxels (relative to the device centre), size in voxels, colour by
|
||||
-- palette index. Faces carry the mod's own directional shade, so the
|
||||
-- slab reads as a solid the way every extruded block here does.
|
||||
local function box(verts, indices, x, y, z, w, h, d, color)
|
||||
local u = (color - 0.5) / #COLORS
|
||||
local vox = Pokedex.VOX
|
||||
local ox, oy, oz = (x - W / 2) * vox, (y - H / 2) * vox, (z - D / 2) * vox
|
||||
local sx, sy, sz = w * vox, h * vox, d * vox
|
||||
for face = 1, 6 do
|
||||
local corners = Voxel3D.FACE_CORNERS[face]
|
||||
local shade = Voxel3D.FACE_SHADE[face]
|
||||
local n = #verts / 4
|
||||
for _, c in ipairs(corners) do
|
||||
verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz,
|
||||
u, 0.5, shade }
|
||||
end
|
||||
Voxel3D.pushQuad(indices, n)
|
||||
end
|
||||
end
|
||||
|
||||
-- the screen's face on the front, in voxels (10:9, the GB frame's shape),
|
||||
-- shared by the dark "off" face in the body and the live quad
|
||||
local SCREEN = { x = 1.2, y = 4.6, w = 6.6, h = 5.94 }
|
||||
|
||||
local function buildBody()
|
||||
if bodyMesh then return bodyMesh end
|
||||
local verts, indices = {}, {}
|
||||
-- the slab, the hinge along the right edge, the lens, the LEDs, the
|
||||
-- d-pad and two chunky buttons -- the classic cover furniture, one box
|
||||
-- each on the front face (z = D..)
|
||||
box(verts, indices, 0, 0, 0, W, H, D, 1) -- body
|
||||
box(verts, indices, W - 0.7, 0, 0, 0.7, H, D + 0.15, 2) -- hinge
|
||||
box(verts, indices, 0.6, H - 2.6, D, 2, 2, 0.5, 3) -- lens
|
||||
box(verts, indices, 0.9, H - 1.3, D + 0.5, 0.6, 0.5, 0.12, 4) -- glint
|
||||
box(verts, indices, 3.2, H - 1.6, D, 0.8, 0.8, 0.35, 5) -- LEDs
|
||||
box(verts, indices, 4.5, H - 1.6, D, 0.8, 0.8, 0.35, 6)
|
||||
box(verts, indices, 5.8, H - 1.6, D, 0.8, 0.8, 0.35, 7)
|
||||
-- the bezel plate the screen sits in, and the dark screen face itself
|
||||
-- (what shows when nothing is on: a device that is off, not a hole)
|
||||
box(verts, indices, SCREEN.x - 0.4, SCREEN.y - 0.4, D,
|
||||
SCREEN.w + 0.8, SCREEN.h + 0.8, 0.4, 8)
|
||||
box(verts, indices, SCREEN.x, SCREEN.y, D + 0.4,
|
||||
SCREEN.w, SCREEN.h, 0.1, 9)
|
||||
-- d-pad below the screen, two crossed bars, and the A/B buttons
|
||||
box(verts, indices, 5.6, 1.1, D, 2.1, 0.7, 0.45, 8)
|
||||
box(verts, indices, 6.3, 0.4, D, 0.7, 2.1, 0.45, 8)
|
||||
box(verts, indices, 1.2, 0.8, D, 1.1, 1.1, 0.45, 5)
|
||||
box(verts, indices, 2.9, 0.8, D, 1.1, 1.1, 0.45, 8)
|
||||
bodyMesh = Voxel3D.newMesh(verts, indices)
|
||||
return bodyMesh
|
||||
end
|
||||
|
||||
-- The live screen: one quad a hair proud of the dark face, UV-mapped to
|
||||
-- `uv` = { u0, v0, u1, v1 } of whatever texture is on it. Rebuilt only
|
||||
-- when the UV rect moves (a window resize moving the battle letterbox).
|
||||
local function buildScreen(uv)
|
||||
local key = table.concat({ uv[1], uv[2], uv[3], uv[4] }, ":")
|
||||
if screenMesh and screenKey == key then return screenMesh end
|
||||
local vox = Pokedex.VOX
|
||||
local x0 = (SCREEN.x - W / 2) * vox
|
||||
local y0 = (SCREEN.y - H / 2) * vox
|
||||
local x1 = x0 + SCREEN.w * vox
|
||||
local y1 = y0 + SCREEN.h * vox
|
||||
local z = (D / 2 + 0.55) * vox
|
||||
local u0, v0, u1, v1 = uv[1], uv[2], uv[3], uv[4]
|
||||
local verts = {
|
||||
{ x0, y0, z, u0, v1, 1 }, { x1, y0, z, u1, v1, 1 },
|
||||
{ x1, y1, z, u1, v0, 1 }, { x0, y1, z, u0, v0, 1 },
|
||||
}
|
||||
local indices = {}
|
||||
Voxel3D.pushQuad(indices, 0)
|
||||
local mesh = Voxel3D.newMesh(verts, indices)
|
||||
if mesh then
|
||||
screenMesh, screenKey = mesh, key
|
||||
end
|
||||
return mesh
|
||||
end
|
||||
|
||||
-- ------- the frame's state, set by VR.lua
|
||||
--
|
||||
-- nil = no pokedex this frame (no session, no tracked left hand).
|
||||
Pokedex.frame = nil
|
||||
|
||||
-- Stand the device on a tracked LEFT-HAND pose under the current
|
||||
-- XR-to-world mapping (the same pivot/anchor/scale/yaw the eyes got).
|
||||
function Pokedex.place(pose, pivot, anchor, scale, yaw)
|
||||
local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||
m = Mat4.mul(m, Mat4.translate(Pokedex.OFFSET[1], Pokedex.OFFSET[2],
|
||||
Pokedex.OFFSET[3]))
|
||||
m = Mat4.mul(m, Mat4.rotateX(Pokedex.TILT))
|
||||
Pokedex.frame = { model = m }
|
||||
end
|
||||
|
||||
-- What the screen shows: a texture and the UV rect of it to fill the
|
||||
-- screen with. nil for a dark screen. Only meaningful after place().
|
||||
function Pokedex.screen(tex, u0, v0, u1, v1)
|
||||
if Pokedex.frame and tex then
|
||||
Pokedex.frame.tex = tex
|
||||
Pokedex.frame.uv = { u0 or 0, v0 or 0, u1 or 1, v1 or 1 }
|
||||
end
|
||||
end
|
||||
|
||||
function Pokedex.clear()
|
||||
Pokedex.frame = nil
|
||||
end
|
||||
|
||||
-- Draw the device with the scene's own pass (model matrix in world px).
|
||||
-- Runs inside VoxelScene's drawScene, per eye; no shadow-caster half --
|
||||
-- a UI prop should receive the world's light, not throw shade on it.
|
||||
function Pokedex.draw()
|
||||
local f = Pokedex.frame
|
||||
if not f then return end
|
||||
local body = buildBody()
|
||||
local pal = palette()
|
||||
if body and pal then
|
||||
Voxel3D.draw(body, pal, f.model)
|
||||
end
|
||||
if f.tex and f.uv then
|
||||
local screen = buildScreen(f.uv)
|
||||
if screen then
|
||||
Voxel3D.draw(screen, f.tex, f.model)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- window resize / hot reload: every GPU object here is derived and cheap
|
||||
function Pokedex.invalidate()
|
||||
paletteTex, bodyMesh, screenMesh, screenKey = nil, nil, nil, nil
|
||||
end
|
||||
|
||||
return Pokedex
|
||||
+149
-53
@@ -190,6 +190,14 @@ uniform float start; // where the checker begins inside a band
|
||||
uniform float axisX; // the "toward the ground" direction on the canvas:
|
||||
uniform float axisY; // (0,1) for a level camera; a rolled VR eye tips
|
||||
// it, and edge/top are distances along it
|
||||
uniform vec3 rayBase; // the eye's ray fan (VRRig eyeCamera.skyRay): a
|
||||
uniform vec3 rayDu; // canvas point at fractions (u, v) looks along
|
||||
uniform vec3 rayDv; // base + u*du + v*dv, world axes -- so each pixel
|
||||
// knows its TRUE elevation and the gradient is a
|
||||
// real skybox, untouched by any head motion
|
||||
uniform float raySpan; // radians of elevation the gradient covers
|
||||
uniform vec2 invSize; // 1/w, 1/h: canvas pixels to fractions
|
||||
uniform float useRay; // 0 = the flat screen's frame-linear gradient
|
||||
uniform float alpha;
|
||||
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
|
||||
uniform vec2 glowPos; // the sun disc, in canvas pixels
|
||||
@@ -207,9 +215,22 @@ vec3 bandAt(float i) {
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec2 cc0 = floor(sc / cell) * cell; // top of this cell
|
||||
float row = cc0.x * axisX + cc0.y * axisY; // along the axis
|
||||
if (row > edge) { discard; } // below the horizon
|
||||
float pos = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0) * count;
|
||||
float tn;
|
||||
if (useRay > 0.5) {
|
||||
// the cell's own ray, and its true elevation: the sky by ANGLE, so
|
||||
// no motion of the head -- pitch, yaw or roll -- moves a band
|
||||
vec2 cc1 = (floor(sc / cell) + 0.5) * cell;
|
||||
vec3 dir = rayBase + rayDu * (cc1.x * invSize.x)
|
||||
+ rayDv * (cc1.y * invSize.y);
|
||||
float elev = atan(dir.y, length(dir.xz));
|
||||
if (elev < 0.0) { discard; } // below the horizon
|
||||
tn = 1.0 - clamp(elev / max(raySpan, 0.001), 0.0, 1.0);
|
||||
} else {
|
||||
float row = cc0.x * axisX + cc0.y * axisY; // along the axis
|
||||
if (row > edge) { discard; } // below the horizon
|
||||
tn = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0);
|
||||
}
|
||||
float pos = tn * count;
|
||||
float base = min(floor(pos), count - 1.0);
|
||||
vec3 c = bandAt(base);
|
||||
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||
@@ -406,40 +427,14 @@ function Sky.discRadius(h, cell, body)
|
||||
return r * cell, r
|
||||
end
|
||||
|
||||
local function paintDisc(body, edge, cell, w, h, axis)
|
||||
local g = love.graphics
|
||||
if not (body and body.y and g.setScissor) then return end
|
||||
local shades = Sky.discShades(body.moon)
|
||||
local twilight = looming(body)
|
||||
local _, r = Sky.discRadius(h, cell, body)
|
||||
-- snap the centre to the cell grid, like everything else in this sky
|
||||
local bx = math.floor(body.x / cell) * cell + cell / 2
|
||||
local by = math.floor(body.y / cell) * cell + cell / 2
|
||||
-- wholly below the horizon point -- measured along the tilt axis when
|
||||
-- the caller has one (a rolled VR eye)
|
||||
local bt = axis and (bx * axis[1] + by * axis[2]) or by
|
||||
if bt - r * cell > edge then return end
|
||||
-- One disc's worth of cell art -- shared verbatim by the screen-space
|
||||
-- painter below (the flat screen) and by the BAKE the VR eyes texture
|
||||
-- their world-anchored quad with (Sky.discImage). `plot(dx, dy, c)` gets
|
||||
-- every kept cell in disc-local cell coordinates and its 0..255 colour.
|
||||
local function discCells(r, moon, shades, twilight, plot)
|
||||
local core = shades[1]
|
||||
local main = shades[twilight and 3 or 2]
|
||||
local sx, sy, sw, sh = g.getScissor()
|
||||
-- the scissor is axis-aligned and cannot follow a tilted horizon; under
|
||||
-- an axis the world drawn after this covers the ground side anyway, so
|
||||
-- the region opens to the frame and only the cull above clips the disc
|
||||
if axis then
|
||||
g.setScissor(0, 0, math.ceil(w), math.ceil(h))
|
||||
else
|
||||
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||
end
|
||||
local craterR = math.max(1, math.floor(r / 5))
|
||||
-- The disc's cells are drawn in the HORIZON'S frame, not the canvas's:
|
||||
-- under an axis (a VR eye that can roll) the whole pattern -- craters,
|
||||
-- rim dither, the cell grid itself -- is rotated to stay upright over
|
||||
-- the world, or a tipped head watches the moon's face spin in place.
|
||||
-- Level cameras rotate by zero and draw exactly what they always drew.
|
||||
local rot = axis and math.atan2(-axis[1], axis[2]) or 0
|
||||
g.push()
|
||||
g.translate(bx, by)
|
||||
if rot ~= 0 then g.rotate(rot) end
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
local d = math.sqrt(dx * dx + dy * dy)
|
||||
@@ -447,7 +442,7 @@ local function paintDisc(body, edge, cell, w, h, axis)
|
||||
local c = d <= r * 0.5 and core or main
|
||||
-- dithered rim: the outer ring keeps only one parity of its cells
|
||||
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
|
||||
if body.moon then
|
||||
if moon then
|
||||
for _, cr in ipairs(MOON_CRATERS) do
|
||||
local cdx = dx - math.floor(cr[1] * r + 0.5)
|
||||
local cdy = dy - math.floor(cr[2] * r + 0.5)
|
||||
@@ -456,19 +451,86 @@ local function paintDisc(body, edge, cell, w, h, axis)
|
||||
end
|
||||
end
|
||||
end
|
||||
if keep then
|
||||
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
g.rectangle("fill", dx * cell - cell / 2,
|
||||
dy * cell - cell / 2, cell, cell)
|
||||
end
|
||||
if keep then plot(dx, dy, c) end
|
||||
end
|
||||
end
|
||||
end
|
||||
g.pop()
|
||||
end
|
||||
|
||||
local function paintDisc(body, edge, cell, w, h)
|
||||
local g = love.graphics
|
||||
if not (body and body.y and g.setScissor) then return end
|
||||
local shades = Sky.discShades(body.moon)
|
||||
local twilight = looming(body)
|
||||
local _, r = Sky.discRadius(h, cell, body)
|
||||
-- snap the centre to the cell grid, like everything else in this sky
|
||||
local bx = math.floor(body.x / cell) * cell + cell / 2
|
||||
local by = math.floor(body.y / cell) * cell + cell / 2
|
||||
if by - r * cell > edge then return end -- wholly below the horizon point
|
||||
local sx, sy, sw, sh = g.getScissor()
|
||||
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||
discCells(r, body.moon, shades, twilight, function(dx, dy, c)
|
||||
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
g.rectangle("fill", bx + dx * cell - cell / 2,
|
||||
by + dy * cell - cell / 2, cell, cell)
|
||||
end)
|
||||
if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- ------- the disc as a TEXTURE, for the VR eyes
|
||||
--
|
||||
-- A VR eye must not paint the disc in screen space at all: a canvas-grid
|
||||
-- painting re-snaps to different cells every head movement (jitter) and
|
||||
-- holds its pattern square to the CANVAS (a rolled or pitched head
|
||||
-- watches the sun's face turn). So the same cell art is baked once into
|
||||
-- a texture, and Voxel3D hangs it on a quad ANCHORED IN THE WORLD --
|
||||
-- projected through the eye's own matrix like any geometry, stable under
|
||||
-- every head motion. Rebaked only when the palette or the twilight state
|
||||
-- moves the colours.
|
||||
local discBake = { key = nil, img = nil }
|
||||
|
||||
Sky.DISC_BAKE_R = 9 -- bake radius, in cells
|
||||
Sky.DISC_BAKE_PX = 8 -- texture pixels per cell
|
||||
|
||||
function Sky.discImage(moon, twilight)
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local shades = Sky.discShades(moon)
|
||||
local key = (moon and "m" or "s") .. (twilight and "t" or "-")
|
||||
for i = 1, math.min(3, #shades) do
|
||||
local c = shades[i]
|
||||
key = key .. ":" .. c[1] .. "," .. c[2] .. "," .. c[3]
|
||||
end
|
||||
if discBake.key == key and discBake.img then return discBake.img end
|
||||
local r, px = Sky.DISC_BAKE_R, Sky.DISC_BAKE_PX
|
||||
local size = (2 * r + 1) * px
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, size, size)
|
||||
if not (ok and canvas) then return nil end
|
||||
pcall(canvas.setFilter, canvas, "nearest", "nearest")
|
||||
local g = love.graphics
|
||||
local done = pcall(function()
|
||||
g.push("all")
|
||||
g.origin()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
discCells(r, moon, shades, twilight, function(dx, dy, c)
|
||||
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
g.rectangle("fill", (dx + r) * px, (dy + r) * px, px, px)
|
||||
end)
|
||||
g.pop()
|
||||
end)
|
||||
if not done then return nil end
|
||||
discBake.key, discBake.img = key, canvas
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- Whether this body is the looming low sun, for callers sizing the baked
|
||||
-- disc (the same exaggeration paintDisc applies through discRadius).
|
||||
function Sky.discLooming(glowAmt, moon)
|
||||
return (glowAmt or 0) > 0.25 and not moon
|
||||
end
|
||||
|
||||
-- Paint the sky into the bound canvas, filling it from the top edge down to
|
||||
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
|
||||
--
|
||||
@@ -488,20 +550,38 @@ end
|
||||
-- {ax, ay} pointing "toward the ground" on the canvas (Voxel3D.horizonLine),
|
||||
-- with `horizonY` and `top` then read as distances ALONG it rather than as
|
||||
-- rows. nil is the level default. Only the shader path can tilt; the flat
|
||||
-- fallback paints level, which only a headless run ever sees.
|
||||
-- fallback paints level, which only a headless run ever sees. Under an
|
||||
-- axis the DISC is not painted here at all -- the VR caller hangs the
|
||||
-- baked disc (Sky.discImage) in the world instead; `body` still carries
|
||||
-- the twilight glow into the bands.
|
||||
--
|
||||
-- `ray` makes the gradient a SKYBOX: the eye's own ray fan (the camera
|
||||
-- record's skyRay, from VRRig.eyeCamera), letting every pixel take its
|
||||
-- band from its TRUE elevation -- so no motion of the head, on any axis,
|
||||
-- moves a band; only the clock does. nil keeps the linear frame gradient
|
||||
-- the flat screen has always painted.
|
||||
--
|
||||
-- Returns false when there is nothing to paint, in which case the caller's flat
|
||||
-- fill is the whole sky. That fill is the palest band, so a frame that declines
|
||||
-- this looks like a hazy day rather than like a bug.
|
||||
function Sky.paint(w, h, sky, horizonY, cell, body, top, axis)
|
||||
function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
|
||||
local bands = sky and sky.bands
|
||||
if not (bands and bands[1]) then return false end
|
||||
if not (w and h and w > 0 and h > 0) then return false end
|
||||
local g = love.graphics
|
||||
if not (g and g.rectangle) then return false end
|
||||
-- along an axis the caller's edge is already the signed distance and
|
||||
-- has no row to be clamped to; level callers keep the SPAN fallback
|
||||
local edge = axis and horizonY or Sky.region(h, horizonY)
|
||||
-- with a ray fan the shader's own per-pixel elevation test is the only
|
||||
-- boundary and the whole frame goes through it; along an axis the
|
||||
-- caller's edge is already the signed distance and has no row to be
|
||||
-- clamped to; level callers keep the SPAN fallback
|
||||
local edge
|
||||
if ray then
|
||||
edge = h
|
||||
elseif axis then
|
||||
edge = horizonY
|
||||
else
|
||||
edge = Sky.region(h, horizonY)
|
||||
end
|
||||
if not edge then return false end
|
||||
local alpha = sky[4] or 1
|
||||
cell = math.max(1, math.floor((cell or 1) + 0.5))
|
||||
@@ -533,6 +613,14 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis)
|
||||
sh:send("top", math.min(top or 0, edge - 1))
|
||||
sh:send("axisX", axis and axis[1] or 0)
|
||||
sh:send("axisY", axis and axis[2] or 1)
|
||||
sh:send("useRay", ray and 1 or 0)
|
||||
if ray then
|
||||
sh:send("rayBase", ray.base)
|
||||
sh:send("rayDu", ray.du)
|
||||
sh:send("rayDv", ray.dv)
|
||||
sh:send("raySpan", Sky.ELEV_SPAN)
|
||||
sh:send("invSize", { 1 / w, 1 / h })
|
||||
end
|
||||
sh:send("cell", cell)
|
||||
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
|
||||
sh:send("alpha", alpha)
|
||||
@@ -547,9 +635,9 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis)
|
||||
if sent then
|
||||
g.setShader(sh)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
-- tilted, the sky's reach is not a row: the full frame goes through
|
||||
-- the shader and the discard is the boundary
|
||||
local rectH = axis and h or math.min(h, math.ceil(edge))
|
||||
-- tilted or rayed, the sky's reach is not a row: the full frame
|
||||
-- goes through the shader and the discard is the boundary
|
||||
local rectH = (axis or ray) and h or math.min(h, math.ceil(edge))
|
||||
g.rectangle("fill", 0, 0, w, rectH)
|
||||
g.setShader()
|
||||
else
|
||||
@@ -557,12 +645,16 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis)
|
||||
end
|
||||
end
|
||||
if not sh then
|
||||
paintFlat(w, h, bands, axis and math.min(h, edge) or edge, alpha, cell,
|
||||
math.min(top or 0, edge - 1))
|
||||
paintFlat(w, h, bands, (axis or ray) and math.min(h, edge) or edge,
|
||||
alpha, cell, math.min(top or 0, edge - 1))
|
||||
end
|
||||
-- the disc goes over the glow, under nothing: plain rectangles, so it is
|
||||
-- there whether or not the shader built
|
||||
paintDisc(body, axis and edge or math.min(h, edge), cell, w, h, axis)
|
||||
-- there whether or not the shader built. NOT under an axis or a ray fan:
|
||||
-- those cameras hang the baked disc in the world instead (drawWorldDisc,
|
||||
-- with Sky.discImage)
|
||||
if not (axis or ray) then
|
||||
paintDisc(body, math.min(h, edge), cell, w, h)
|
||||
end
|
||||
g.setColor(1, 1, 1, 1)
|
||||
|
||||
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
|
||||
@@ -578,6 +670,10 @@ function Sky.invalidate()
|
||||
shader = nil
|
||||
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||
cache.ramp, cache.rampFor = nil, nil
|
||||
if discBake.img and discBake.img.release then
|
||||
pcall(discBake.img.release, discBake.img)
|
||||
end
|
||||
discBake.key, discBake.img = nil, nil
|
||||
end
|
||||
|
||||
return Sky
|
||||
|
||||
@@ -156,6 +156,29 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
|
||||
fov.angleUp, fov.angleDown,
|
||||
VRRig.NEAR, VRRig.FAR)
|
||||
|
||||
-- The eye's RAY FAN, in world axes: the direction a canvas point
|
||||
-- (u, v in 0..1, left-to-right and top-to-bottom) looks along is
|
||||
-- base + u * du + v * dv. The sky reads its per-pixel TRUE elevation
|
||||
-- off this (a real skybox cannot be painted from any per-frame row
|
||||
-- mapping -- that is exact only at the view's own azimuth and swims
|
||||
-- everywhere else). Directions only, so the mapping's scale drops out;
|
||||
-- the yaw must not (the battle mount and the snap turn swing the world).
|
||||
local Rw = R
|
||||
if yaw and yaw ~= 0 then Rw = Mat4.mul(Mat4.rotateY(yaw), R) end
|
||||
local tl, tr = math.tan(fov.angleLeft), math.tan(fov.angleRight)
|
||||
local tu, td = math.tan(fov.angleUp), math.tan(fov.angleDown)
|
||||
-- world columns of the head's rotation: right (X), up (Y), forward (-Z)
|
||||
local rxc, ryc, rzc = Rw[1], Rw[5], Rw[9]
|
||||
local uxc, uyc, uzc = Rw[2], Rw[6], Rw[10]
|
||||
local fxc, fyc, fzc = -Rw[3], -Rw[7], -Rw[11]
|
||||
local skyRay = {
|
||||
base = { fxc + rxc * tl + uxc * tu,
|
||||
fyc + ryc * tl + uyc * tu,
|
||||
fzc + rzc * tl + uzc * tu },
|
||||
du = { rxc * (tr - tl), ryc * (tr - tl), rzc * (tr - tl) },
|
||||
dv = { uxc * (td - tu), uyc * (td - tu), uzc * (td - tu) },
|
||||
}
|
||||
|
||||
-- the eye and its forward, in world pixels: worldFromEye applied to the
|
||||
-- origin and to -Z
|
||||
local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3]
|
||||
@@ -177,6 +200,7 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
|
||||
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
|
||||
fov = fov.angleUp - fov.angleDown,
|
||||
curve = 0,
|
||||
skyRay = skyRay,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
+128
-45
@@ -444,6 +444,13 @@ end
|
||||
-- way either way.
|
||||
Voxel3D.camera = nil
|
||||
|
||||
-- This frame's camera RAY FAN, set by viewProjection alongside vp: the
|
||||
-- world direction a canvas point looks along (see Sky.paint's `ray`).
|
||||
-- Present for every free-pitch camera -- the VR eyes bring theirs
|
||||
-- (VRRig.eyeCamera), a placed eye/focus camera gets one built -- and nil
|
||||
-- for the orbit, whose frame-hung sky is the classic look.
|
||||
Voxel3D.skyRayLive = nil
|
||||
|
||||
-- ------- which way, and how steeply, this camera looks
|
||||
--
|
||||
-- Two facts about the view direction, set alongside the eye and the focus
|
||||
@@ -495,6 +502,8 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
-- reason as every other branch here
|
||||
if cam.view and cam.proj then
|
||||
Voxel3D.fovY = cam.fov
|
||||
-- the VR eyes bring their fan with them (VRRig.eyeCamera)
|
||||
Voxel3D.skyRayLive = cam.skyRay
|
||||
return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view)
|
||||
end
|
||||
local dx = eye[1] - focus[1]
|
||||
@@ -510,6 +519,35 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
-- 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)
|
||||
-- The camera's RAY FAN, for the sky's skybox path (Sky.paint's `ray`):
|
||||
-- a placed camera with a FREE PITCH -- the first-person rig, steered
|
||||
-- by a mouse on the flat screen -- must not hang its gradient off the
|
||||
-- frame, or looking up and down drags the bands with the view. Built
|
||||
-- from the very basis the view below is: forward, the true right, the
|
||||
-- true up, and the symmetric frustum's tangents.
|
||||
local upv = cam.up or { 0, 1, 0 }
|
||||
local fx, fy, fz = -dx / dist, -dy / dist, -dz / dist
|
||||
local crx = fy * upv[3] - fz * upv[2]
|
||||
local cry = fz * upv[1] - fx * upv[3]
|
||||
local crz = fx * upv[2] - fy * upv[1]
|
||||
local crl = math.sqrt(crx * crx + cry * cry + crz * crz)
|
||||
if crl > 1e-6 then
|
||||
crx, cry, crz = crx / crl, cry / crl, crz / crl
|
||||
local cux = cry * fz - crz * fy
|
||||
local cuy = crz * fx - crx * fz
|
||||
local cuz = crx * fy - cry * fx
|
||||
local tanY = math.tan(cam.fov / 2)
|
||||
local tanX = tanY * (vw / vh)
|
||||
Voxel3D.skyRayLive = {
|
||||
base = { fx - crx * tanX + cux * tanY,
|
||||
fy - cry * tanX + cuy * tanY,
|
||||
fz - crz * tanX + cuz * tanY },
|
||||
du = { crx * 2 * tanX, cry * 2 * tanX, crz * 2 * tanX },
|
||||
dv = { cux * -2 * tanY, cuy * -2 * tanY, cuz * -2 * tanY },
|
||||
}
|
||||
else
|
||||
Voxel3D.skyRayLive = nil
|
||||
end
|
||||
-- world up by default, so the horizon stays level -- a placed camera
|
||||
-- that rolled with its own pitch would tip the whole arena. A caller
|
||||
-- may hand its own up: the first-person BLEND does, because its far
|
||||
@@ -518,6 +556,10 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 }))
|
||||
end
|
||||
|
||||
-- the orbit: a fixed pitch per rung, and the classic frame-hung sky --
|
||||
-- no ray fan wanted
|
||||
Voxel3D.skyRayLive = nil
|
||||
|
||||
local a = Voxel.angle
|
||||
local focal = Voxel.FOCAL
|
||||
local dist = focal * vh
|
||||
@@ -685,6 +727,72 @@ function Voxel3D.skyBody(w, h)
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- the VR sky's world-anchored pieces
|
||||
--
|
||||
-- Both exist because a headset showed the shortcuts: a gradient painted
|
||||
-- off the frame moved with the head that carried the frame, and a
|
||||
-- screen-space disc re-snapped its cell grid with every head movement
|
||||
-- and held its face square to the canvas instead of to the world. The
|
||||
-- gradient's fix rides the camera record itself (skyRay -- see VRRig and
|
||||
-- Sky's useRay path); the disc's is below.
|
||||
|
||||
-- The sun or moon as a QUAD IN THE WORLD: the baked cell art
|
||||
-- (Sky.discImage) on a square spanned about the hour's direction, its
|
||||
-- corners projected through this very eye -- so the disc is pinned to
|
||||
-- the sky like the terrain is to the ground, stable under every head
|
||||
-- motion, its face upright over the world. Runs inside beginScene's sky
|
||||
-- window, before the depth mode is set, so the world draws over it.
|
||||
local discMesh = nil
|
||||
|
||||
local function drawWorldDisc(w, h)
|
||||
local b = DayNight.body()
|
||||
if not (b and b.dy and b.dy > 0.005) then return end
|
||||
local amt = DayNight.glow()
|
||||
local img = Sky.discImage(b.moon, Sky.discLooming(amt, b.moon))
|
||||
if not img then return end
|
||||
local m = Voxel3D.vp
|
||||
if not m then return end
|
||||
local hl = math.sqrt(b.dx * b.dx + b.dz * b.dz)
|
||||
if hl < 1e-6 then return end
|
||||
-- right = horizontal, perpendicular to the direction; up completes it
|
||||
local rx, rz = b.dz / hl, -b.dx / hl
|
||||
local ux = -rz * b.dy
|
||||
local uy = rz * b.dx - rx * b.dz
|
||||
local uz = rx * b.dy
|
||||
local ul = math.sqrt(ux * ux + uy * uy + uz * uz)
|
||||
if ul < 1e-6 then return end
|
||||
ux, uy, uz = ux / ul, uy / ul, uz / ul
|
||||
if uy < 0 then ux, uy, uz = -ux, -uy, -uz end
|
||||
-- apparent size is an ANGLE, the same fraction of the view the flat
|
||||
-- screen's disc takes of its frame; the low sun looms exactly as there
|
||||
local ang = Sky.DISC_FRAC * (Voxel3D.fovY or 1)
|
||||
if Sky.discLooming(amt, b.moon) then ang = ang * 1.4 end
|
||||
local k = math.tan(ang)
|
||||
local verts = {}
|
||||
local corners = { { -1, -1, 0, 1 }, { 1, -1, 1, 1 },
|
||||
{ 1, 1, 1, 0 }, { -1, 1, 0, 0 } }
|
||||
for i, c in ipairs(corners) do
|
||||
local vx = b.dx + (rx * c[1] + ux * c[2]) * k
|
||||
local vy = b.dy + (uy * c[2]) * k
|
||||
local vz = b.dz + (rz * c[1] + uz * c[2]) * k
|
||||
local x = m[1] * vx + m[2] * vy + m[3] * vz
|
||||
local y = m[5] * vx + m[6] * vy + m[7] * vz
|
||||
local ww = m[13] * vx + m[14] * vy + m[15] * vz
|
||||
if ww <= 1e-6 then return end
|
||||
verts[i] = { (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h,
|
||||
c[3], c[4] }
|
||||
end
|
||||
pcall(function()
|
||||
if not discMesh then
|
||||
discMesh = love.graphics.newMesh(4, "fan", "stream")
|
||||
end
|
||||
discMesh:setVertices(verts)
|
||||
discMesh:setTexture(img)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(discMesh)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------------- scene --
|
||||
|
||||
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
|
||||
@@ -739,38 +847,20 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
-- screen. The sky's dither grid is cut to it, and so is the water's --
|
||||
-- one number, so the two break up on the same checkerboard.
|
||||
Voxel3D.cell = w / math.max(1, vw or w)
|
||||
-- A VR eye's sky is ANCHORED IN SPACE, where the flat screen's is glued
|
||||
-- to the frame. The differences all key off the raw-matrix camera only
|
||||
-- the VR eyes bring: the horizon must really be in frame for any band
|
||||
-- to paint (no Sky.SPAN fallback -- that slice pinned to the top of the
|
||||
-- view is exactly "the sky moves with the headset"); the gradient hangs
|
||||
-- over a fixed ELEVATION span above the horizon rather than filling up
|
||||
-- to the frame's edge; the whole painting runs along the horizon's OWN
|
||||
-- AXIS (horizonLine), so a rolled head sees the horizon hold level in
|
||||
-- the world instead of hinging with the ears; and the sun or moon was
|
||||
-- already honest -- skyBody projects the hour's direction through this
|
||||
-- very eye.
|
||||
local vrEye = Voxel3D.camera and Voxel3D.camera.view and Voxel3D.camera.proj
|
||||
and true or false
|
||||
-- A FREE-PITCH camera's sky is ANCHORED IN SPACE, where the orbit's is
|
||||
-- glued to the frame. One discriminator: skyRayLive, set by
|
||||
-- viewProjection above for every camera whose pitch the player steers
|
||||
-- -- the VR eyes and the flat first-person rig alike. With a fan, the
|
||||
-- gradient is a SKYBOX (every pixel takes its band, and its GBC
|
||||
-- checker, from its ray's true elevation -- no motion of the camera
|
||||
-- moves a band, only the clock recolours them) and the sun or moon
|
||||
-- hangs in the WORLD (drawWorldDisc). Without one -- the orbit, whose
|
||||
-- pitch is the rung's -- the classic frame-hung painting stands.
|
||||
local skyRay = Voxel3D.skyRayLive
|
||||
local hy = Voxel3D.horizonY(h)
|
||||
local ax, ay, edgeT, topT
|
||||
if vrEye then
|
||||
ax, ay, edgeT, topT = Voxel3D.horizonLine(w, h, Sky.ELEV_SPAN)
|
||||
end
|
||||
-- any sky in frame at all? the corner most toward the sky must sit
|
||||
-- above the horizon's line
|
||||
local skyUp = false
|
||||
if sky and sky.bands then
|
||||
if not vrEye then
|
||||
skyUp = true
|
||||
elseif edgeT then
|
||||
local minT = math.min(0, w * ax, h * ay, w * ax + h * ay)
|
||||
skyUp = edgeT > minT + 1
|
||||
end
|
||||
end
|
||||
-- and where the sky's bottom edge lands, which is what the reflection
|
||||
-- where the sky's bottom edge lands, which is what the reflection
|
||||
-- reads its bands against (see Water). nil when nothing painted bands.
|
||||
Voxel3D.skyEdge = skyUp and Sky.region(h, hy) or nil
|
||||
Voxel3D.skyEdge = (sky and sky.bands) and Sky.region(h, hy) or nil
|
||||
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
|
||||
@@ -783,24 +873,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
-- 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.
|
||||
if not vrEye then
|
||||
if skyRay and sky.bands then
|
||||
Sky.paint(w, h, sky, nil, Voxel3D.cell, Voxel3D.skyBody(w, h),
|
||||
nil, nil, skyRay)
|
||||
drawWorldDisc(w, h)
|
||||
else
|
||||
Sky.paint(w, h, sky, hy, Voxel3D.cell,
|
||||
sky.bands and Voxel3D.skyBody(w, h) or nil)
|
||||
elseif skyUp then
|
||||
-- the gradient's far end: the ELEV_SPAN direction's own projection
|
||||
-- when the frustum holds it (horizonLine's `top` -- exact, so a
|
||||
-- pitch slides the frame over bands that stay put), and the
|
||||
-- pixels-per-radian estimate when it does not
|
||||
local top = topT
|
||||
if not (top and top < edgeT - 1) then
|
||||
local radPerPx = math.max(1e-6, (Voxel3D.fovY or 1) / h)
|
||||
top = edgeT - Sky.ELEV_SPAN / radPerPx
|
||||
end
|
||||
Sky.paint(w, h, sky, edgeT, Voxel3D.cell,
|
||||
Voxel3D.skyBody(w, h), top, { ax, ay })
|
||||
end
|
||||
-- a VR eye with no horizon in frame paints nothing: everything in view
|
||||
-- is below the horizon, and the haze clear above already filled it
|
||||
else
|
||||
love.graphics.clear(0, 0, 0, 0, true, true)
|
||||
end
|
||||
@@ -1273,6 +1353,9 @@ function Voxel3D.invalidate()
|
||||
end
|
||||
canvas, canvasW, canvasH = nil, 0, 0
|
||||
held = nil
|
||||
-- the VR sky's disc mesh belongs to this context like the canvases do
|
||||
if discMesh and discMesh.release then pcall(discMesh.release, discMesh) end
|
||||
discMesh = nil
|
||||
ShadowMap.invalidate()
|
||||
-- the sky is part of this pass and holds a shader of its own
|
||||
Sky.invalidate()
|
||||
|
||||
@@ -501,6 +501,29 @@ local HOTKEYS = {
|
||||
["9"] = Water.setting,
|
||||
}
|
||||
|
||||
-- One step of the VOXEL angle ladder: everything a "3" press does, named
|
||||
-- so the pad's SELECT button (below) can make exactly the same step. The
|
||||
-- gate is the registry's own; the tilt/GBC FX clearing is the engine work
|
||||
-- the key has always delegated (see the wrap below for why).
|
||||
local function cycleVoxel(game)
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local top = game.stack and game.stack:top()
|
||||
if not Pipelines.canToggle("voxel", top, game.overworld) then return false end
|
||||
Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
|
||||
Pipelines.syncOptions(game.save.options)
|
||||
-- 3 is the key that used to turn TILT on and sits next to the one that
|
||||
-- used to turn GBC FX on, and this mod has taken both away. A player who
|
||||
-- left either running before enabling the mod would otherwise have no
|
||||
-- way back to off, and both fight the diorama -- so the VOXEL step
|
||||
-- clears them on EVERY press, not just the press that switches on.
|
||||
game.save.options.tilt = 0
|
||||
game.save.options.gbcfx = 0
|
||||
require("src.render.GBCFX").setLevel(0)
|
||||
require("src.render.Tilt").setLevel(game.save.options.tilt or 0)
|
||||
game:writeOptions()
|
||||
return true
|
||||
end
|
||||
|
||||
do
|
||||
local Game = require("src.core.Game")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
@@ -517,32 +540,12 @@ do
|
||||
-- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
|
||||
-- so the registry's plain "advance one and wrap" is not what it
|
||||
-- wants; 6 still is. The gate is the registry's own either way.
|
||||
local stepped = false
|
||||
-- The whole of 3's step lives in cycleVoxel, because the pad's
|
||||
-- SELECT button makes the same step (see the handleInput wrap).
|
||||
if key == "3" then
|
||||
if Pipelines.canToggle("voxel", top, self.overworld) then
|
||||
Pipelines.setLevel("voxel",
|
||||
Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
|
||||
stepped = true
|
||||
end
|
||||
else
|
||||
stepped = Pipelines.hotkey(key, top, self.overworld) and true
|
||||
end
|
||||
if stepped then
|
||||
if cycleVoxel(self) then return end
|
||||
elseif Pipelines.hotkey(key, top, self.overworld) then
|
||||
Pipelines.syncOptions(self.save.options)
|
||||
-- 3 is the key that used to turn TILT on and sits next to the one
|
||||
-- that used to turn GBC FX on, and this mod has taken both away.
|
||||
-- A player who left either running before enabling the mod would
|
||||
-- otherwise have no way back to off, and both fight the diorama:
|
||||
-- TILT is the flat fake of what this mode does for real, and GBC
|
||||
-- FX is a full-screen present pass over the top of it. So the
|
||||
-- VOXEL key clears them on EVERY press, not just the press that
|
||||
-- switches the mode on -- cycling back round to OFF leaves them
|
||||
-- off too, which is the state the key is now the only route to.
|
||||
if key == "3" then
|
||||
self.save.options.tilt = 0
|
||||
self.save.options.gbcfx = 0
|
||||
require("src.render.GBCFX").setLevel(0)
|
||||
end
|
||||
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
|
||||
self:writeOptions()
|
||||
return
|
||||
@@ -878,6 +881,38 @@ OverworldBattle.install()
|
||||
FirstPerson.install()
|
||||
FreeMove.install()
|
||||
|
||||
-- ------- SELECT walks the angle ladder
|
||||
--
|
||||
-- The same step the "3" key makes, on the pad's own button: a phone (and
|
||||
-- a controller) has no number row, and SELECT has no overworld job in
|
||||
-- Gen 1 -- its work is all in-menu, which this wrap never sees. The seam
|
||||
-- is OverworldState:handleInput, the same choke point the free walk
|
||||
-- replaced: every gate above it -- menus, dialogs, scripted moves,
|
||||
-- transitions -- already decided the overworld owns the buttons, so a
|
||||
-- SELECT here is free-roam by construction, exactly like the key. When
|
||||
-- the step is refused (mid-warp, no 3D pass) the press falls through to
|
||||
-- the engine's own handling, which is a no-op, as ever.
|
||||
--
|
||||
-- Installed AFTER FreeMove.install, deliberately: its wrap must sit
|
||||
-- OUTSIDE the free walk's, or first person -- where FreeMove.tick takes
|
||||
-- the frame and never calls further in -- would eat the button, and the
|
||||
-- one rung SELECT could not step off of would be 1ST itself.
|
||||
do
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if not OverworldState.dramaticShapeSelectHook then
|
||||
local inner = OverworldState.handleInput
|
||||
function OverworldState:handleInput(...)
|
||||
local Game = require("src.core.Game")
|
||||
local input = Game.input
|
||||
if input and input.wasPressed and input:wasPressed("select") then
|
||||
if cycleVoxel(Game) then return end
|
||||
end
|
||||
return inner(self, ...)
|
||||
end
|
||||
OverworldState.dramaticShapeSelectHook = true
|
||||
end
|
||||
end
|
||||
|
||||
-- The overworld's own pushBattle is the choke point for a wild encounter or
|
||||
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
|
||||
-- link battle, a script pushing a BattleState directly -- reaches this
|
||||
@@ -979,7 +1014,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.6.0"
|
||||
mod.exports.version = "1.5.2"
|
||||
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
||||
-- camera without reaching into this mod's file layout
|
||||
mod.exports.lib = V
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.6.0",
|
||||
"version": "1.5.2",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
@@ -16,6 +16,7 @@ return {
|
||||
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
||||
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
|
||||
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||
"SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched",
|
||||
"on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
|
||||
"on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
|
||||
},
|
||||
|
||||
@@ -1126,6 +1126,34 @@ T.eq(Battles.setting:get(), false, "8 toggles overworld battles off")
|
||||
Game.keypressed(keyGame, "8")
|
||||
T.eq(Battles.setting:get(), true, "and back on")
|
||||
|
||||
-- ------- SELECT makes the same step the 3 key does
|
||||
--
|
||||
-- The pad's own button, for the machines with no number row. The wrap
|
||||
-- sits on OverworldState:handleInput -- free-roam by construction -- and
|
||||
-- reads the live Game's input, so the fake free-roam shape the key tests
|
||||
-- built is lent to the Game module for the length of the check.
|
||||
-- (An anonymous function scope: the main chunk sits AT LuaJIT's
|
||||
-- 200-active-locals ceiling, so even a named wrapper is one too many.)
|
||||
;(function()
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
T.eq(OverworldState.dramaticShapeSelectHook, true,
|
||||
"the SELECT wrap is installed on the overworld input seam")
|
||||
local hadInput, hadStack = Game.input, Game.stack
|
||||
local hadOw, hadSave, hadWrite = Game.overworld, Game.save, Game.writeOptions
|
||||
Game.input = { wasPressed = function(_, b) return b == "select" end }
|
||||
Game.stack, Game.overworld = keyGame.stack, keyGame.overworld
|
||||
Game.save, Game.writeOptions = keyGame.save, keyGame.writeOptions
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
OverworldState.handleInput({})
|
||||
T.eq(Pipelines.levelLabel("voxel"), "15",
|
||||
"SELECT steps the VOXEL ladder exactly as 3 does")
|
||||
OverworldState.handleInput({})
|
||||
T.eq(Pipelines.levelLabel("voxel"), "35", "and keeps walking it")
|
||||
Game.input, Game.stack = hadInput, hadStack
|
||||
Game.overworld, Game.save, Game.writeOptions = hadOw, hadSave, hadWrite
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
end)()
|
||||
|
||||
-- 3 also clears the two engine modes it displaced. Without this a player
|
||||
-- who left TILT or GBC FX on before enabling the mod has no key left to
|
||||
-- turn them off with, and both fight the diorama -- TILT is the flat fake
|
||||
@@ -3807,6 +3835,32 @@ local wx2, wy2, wz2 = apply(seated.view, 150 - VRRig.FP_SCALE, 10, 200)
|
||||
T.check(near(wx2, 0) and near(wy2, 0) and near(wz2, -1),
|
||||
"the yawed view carries one metre west of the pivot to one metre ahead")
|
||||
|
||||
-- the sky's RAY FAN round-trips the eye's own projection: the direction
|
||||
-- the fan hands a canvas point looks along projects back to that very
|
||||
-- point, through a rotated head AND a yawed mapping -- one sign wrong
|
||||
-- anywhere here and the skybox paints sideways or upside down
|
||||
local rayCam = VRRig.eyeCamera({ pos = { 0.2, 1.1, -0.4 },
|
||||
quat = { 0, s, 0, math.cos(math.pi / 4) } },
|
||||
fov, { 50, 0, 70 }, { 0, 0, 0 }, 10,
|
||||
math.pi / 3)
|
||||
local rm = Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), rayCam.proj), rayCam.view)
|
||||
local function rayFrac(u, v)
|
||||
local sr = rayCam.skyRay
|
||||
local d1 = sr.base[1] + u * sr.du[1] + v * sr.dv[1]
|
||||
local d2 = sr.base[2] + u * sr.du[2] + v * sr.dv[2]
|
||||
local d3 = sr.base[3] + u * sr.du[3] + v * sr.dv[3]
|
||||
local x = rm[1] * d1 + rm[2] * d2 + rm[3] * d3
|
||||
local y = rm[5] * d1 + rm[6] * d2 + rm[7] * d3
|
||||
local ww = rm[13] * d1 + rm[14] * d2 + rm[15] * d3
|
||||
return x / ww * 0.5 + 0.5, y / ww * 0.5 + 0.5
|
||||
end
|
||||
local fu, fv = rayFrac(0.3, 0.8)
|
||||
T.check(near(fu, 0.3, 1e-4) and near(fv, 0.8, 1e-4),
|
||||
"the sky's ray fan round-trips the eye's own projection")
|
||||
local fu2, fv2 = rayFrac(0.9, 0.1)
|
||||
T.check(near(fu2, 0.9, 1e-4) and near(fv2, 0.1, 1e-4),
|
||||
"at every corner of the frame alike")
|
||||
|
||||
-- ------- the hand prop: the pokedex rides the same mapping as the eyes
|
||||
--
|
||||
-- propMatrix carries a hand pose through worldFromXr: an identity hand at
|
||||
@@ -3896,6 +3950,31 @@ T.check(near(bex, 96, 1e-3) and near(bey, 10, 1e-3) and near(bez, 192, 1e-3),
|
||||
"and the enemy mark over the enemy's")
|
||||
V3D_.eye = hadEye
|
||||
|
||||
-- the FLAT first-person rig's sky fan: a placed eye/focus camera through
|
||||
-- viewProjection carries a ray fan of its own, and it round-trips that
|
||||
-- projection exactly like the VR eyes' does -- the flat 1ST sky is a
|
||||
-- skybox by the same math
|
||||
local hadCam = V3D_.camera
|
||||
V3D_.camera = { eye = { 100, 13, 200 }, focus = { 130, 18, 160 },
|
||||
fov = 1.1, up = { 0, 1, 0 } }
|
||||
local pvp = V3D_.viewProjection(0, 0, 320, 288)
|
||||
local psr = V3D_.skyRayLive
|
||||
T.check(psr ~= nil, "a placed free-pitch camera carries a ray fan")
|
||||
local function pFrac(u, v)
|
||||
local d1 = psr.base[1] + u * psr.du[1] + v * psr.dv[1]
|
||||
local d2 = psr.base[2] + u * psr.du[2] + v * psr.dv[2]
|
||||
local d3 = psr.base[3] + u * psr.du[3] + v * psr.dv[3]
|
||||
local x = pvp[1] * d1 + pvp[2] * d2 + pvp[3] * d3
|
||||
local y = pvp[5] * d1 + pvp[6] * d2 + pvp[7] * d3
|
||||
local ww = pvp[13] * d1 + pvp[14] * d2 + pvp[15] * d3
|
||||
return x / ww * 0.5 + 0.5, y / ww * 0.5 + 0.5
|
||||
end
|
||||
local pu, pv = pFrac(0.25, 0.6)
|
||||
T.check(near(pu, 0.25, 1e-4) and near(pv, 0.6, 1e-4),
|
||||
"and it round-trips the placed camera's own projection")
|
||||
V3D_.camera = hadCam
|
||||
V3D_.skyRayLive = nil
|
||||
|
||||
-- ------- VR owns the battle rows while it is on
|
||||
local VRSet = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR").setting
|
||||
VRSet:sync(true)
|
||||
|
||||
Reference in New Issue
Block a user