mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 16:10:58 +02:00
394 lines
18 KiB
Lua
394 lines
18 KiB
Lua
-- RENDER DIST: how much of the map the orbit rungs bother to draw.
|
|
--
|
|
-- lib/Diorama cuts a headset's model out of the world with a box the
|
|
-- player opens and closes with their hands. This is the same cut on the
|
|
-- flat screen, asked the other way round: not "how much world do I want to
|
|
-- be holding" but "how much world can this camera actually SEE" -- so that
|
|
-- nothing off screen is drawn, and nothing on screen is missing.
|
|
--
|
|
-- THE FOOTPRINT IS NOT THE WINDOW. That is the whole difficulty, and the
|
|
-- first cut of this file got it wrong: it took the flat game's own vw-by-vh
|
|
-- rectangle about the view centre, which is exactly right at 0 degrees and
|
|
-- wrong at every rung the mode actually has. Tilt the camera and the ground
|
|
-- it frames stops being that rectangle and becomes a TRAPEZOID -- reaching
|
|
-- much further north (the far edge of the frame is further away, so it
|
|
-- covers more ground per pixel), flaring much wider there for the same
|
|
-- reason, and pulling IN at the south edge, which is nearer the eye than
|
|
-- the focus is. A window-sized box cuts the north field and both far
|
|
-- corners off a world that is plainly on screen: gaps at the top and down
|
|
-- the sides, with sky showing through them.
|
|
--
|
|
-- So the footprint is derived from the camera rather than guessed. The
|
|
-- orbit is one number (see Voxel3D.viewProjection): eye at distance
|
|
-- FOCAL*vh, pitched `a` off straight down, looking at the view centre,
|
|
-- with a fov chosen so a straight-down camera frames exactly vh. Cast the
|
|
-- frame's own corner rays at the ground plane and the trapezoid falls out
|
|
-- in closed form -- see footprint() for the derivation, which is three
|
|
-- lines of algebra and no tuning at all.
|
|
--
|
|
-- AND THE GROUND IS NOT THE PICTURE. The trapezoid is where the frame's
|
|
-- rays LAND; what is drawn is what stands on it, and a tree at the bottom
|
|
-- of the screen has its feet south of the row its top is seen on. Cut to
|
|
-- the trapezoid alone, the box takes that tree away whole -- the cut is by
|
|
-- column, so a base one pixel outside loses the whole height -- and the
|
|
-- bottom of the frame reads as a bite taken out of the scenery. So the
|
|
-- south edge is walked back down the bottom ray by the tallest thing that
|
|
-- can stand there; see lift().
|
|
--
|
|
-- The CUT is still a rectangle (the shader's box kind), so what is stored
|
|
-- is the trapezoid's bounding rect: never narrower than the picture, so it
|
|
-- can never take a bite out of it. It is off-centre in z, because the
|
|
-- trapezoid is -- the box sits north of the view centre at every rung but
|
|
-- the top one.
|
|
--
|
|
-- THE HORIZON IS WHY THERE IS A ROW AT ALL. Past about 63 degrees (exactly
|
|
-- atan(2*FOCAL), where the top of the frame lifts off the ground plane) the
|
|
-- trapezoid stops being finite: the camera can see to the horizon, and "all
|
|
-- the ground on screen" is an infinite answer. Something has to name a
|
|
-- distance, and that is what RENDER DIST names -- MAX_REACH view heights,
|
|
-- times the row's own multiplier. Below that pitch the row does nothing to
|
|
-- the picture at all, because the honest footprint is already smaller than
|
|
-- the reach; at 75 it is what decides where the world ends.
|
|
--
|
|
-- AND IT PAYS FOR ITSELF. A cut this file can describe in world pixels is
|
|
-- one VoxelScene can test a whole neighbour map against BEFORE drawing it
|
|
-- -- see shows() -- so a connected map that lands entirely outside the box
|
|
-- costs no terrain mesh, no water, no grass, no flowers and no shadow
|
|
-- pass. Most of the win is at the HIGH rungs, where the camera is nearly
|
|
-- overhead and the footprint is barely bigger than the window; at 75 it
|
|
-- sees half the region and skips almost nothing, which is the truth about
|
|
-- that rung rather than a shortcoming of the test.
|
|
--
|
|
-- WHAT IT DOES NOT TOUCH. The free-roam rungs (1ST and 3RD): the player is
|
|
-- standing IN the world there, and a box around a walking eye is a
|
|
-- fog-of-war circle rather than a model on a table. The rung tween into
|
|
-- them opens the box out with the blend rather than dropping it on a
|
|
-- frame, so diving into a head does not pop the sides away. A headset's
|
|
-- frame is not touched either -- lib/Diorama owns the cut there, and VR's
|
|
-- STANDARD rungs are a tabletop already.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local ModSetting = V.require("ModSetting")
|
|
|
|
local ViewBox = {}
|
|
|
|
ViewBox.KEY = "viewbox"
|
|
-- RENDER DIST rather than a V- name like the rows either side of it: what
|
|
-- the player is choosing is HOW MUCH WORLD gets drawn, which is the thing
|
|
-- every game with this row calls a render distance. The mode read -- the
|
|
-- slab with sides -- is what that buys, not what the row is asking.
|
|
ViewBox.LABEL = "RENDER DIST"
|
|
|
|
-- The ladder, as a multiplier on the footprint the camera actually frames.
|
|
-- Rung 0 is FIT and it is the default, and FIT means exactly that: the
|
|
-- ground on screen and no more. It cannot open a gap -- 1.0 times the
|
|
-- honest answer is the honest answer -- so the only thing the wider rungs
|
|
-- buy below the horizon pitch is margin around a cut nobody can see.
|
|
--
|
|
-- Where they DO decide the picture is at 75, and at the tween rungs either
|
|
-- side of it, where the footprint is infinite and MAX_REACH below stands in
|
|
-- for it: there the ladder is a real render distance and FIT is the closest
|
|
-- horizon of the four.
|
|
--
|
|
-- Geometric rather than even, for the reason WorldCurve's ladder is: what
|
|
-- the player sees change between two rungs is the AREA inside the box, and
|
|
-- that goes as the square -- even steps bunch the whole ladder at the
|
|
-- near end.
|
|
--
|
|
-- The last rung is 0, which is no cut at all. It sits at the TOP because
|
|
-- "everything" is where the ladder is going: FIT, wider, wider, wider,
|
|
-- all of it.
|
|
ViewBox.FRACS = { 1.0, 1.5, 2.25, 3.5, 0 }
|
|
|
|
ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
|
|
{ 0, 1, 2, 3, 4 },
|
|
{ "FIT", "WIDE", "WIDER", "WIDEST", "OFF" })
|
|
|
|
-- How far the world may reach when the camera can see the HORIZON and the
|
|
-- honest footprint is infinite, in view heights. Generous on purpose: this
|
|
-- is a backstop for an unbounded answer, not a curtain to draw across the
|
|
-- middle distance, and it wants to land well past the edge of the loaded
|
|
-- neighbourhood so the world runs out before the cut does. Multiplied by
|
|
-- the row, so a player who can see the seam can push it away.
|
|
ViewBox.MAX_REACH = 6
|
|
|
|
-- ------- the geometry standing on the ground it frames
|
|
--
|
|
-- The footprint is where the frame's rays hit the GROUND, and the ground is
|
|
-- not what the picture is made of. A tree is most of a hundred world pixels
|
|
-- tall, and a point that high up on the BOTTOM edge's own ray sits south of
|
|
-- where that ray lands -- nearer the eye, because the ray is coming down. So
|
|
-- the bottom of the screen is full of things whose feet are outside the
|
|
-- ground trapezoid, and a box cut to the trapezoid alone takes them away
|
|
-- whole: the shader cuts a fragment by the column it stands in (Voxel3D's
|
|
-- dioramaCull is unbounded upward, deliberately, so a cut never takes the
|
|
-- tops off trees), so a tree one pixel south of the edge loses its whole
|
|
-- height at once. That is a bite along the bottom of the picture -- a row of
|
|
-- trees cut through by the frame's own edge, with the ground behind them
|
|
-- showing.
|
|
--
|
|
-- HEIGHT is the tallest thing standing on that ground, and it is the sun
|
|
-- pass's own figure for the same reason it needs one: how far outside the
|
|
-- ground it fits can something still reach the picture? Kept here rather
|
|
-- than read across so this file's cut does not move when the light's
|
|
-- frustum is retuned; they answer to the same world either way.
|
|
ViewBox.HEIGHT = 160
|
|
|
|
-- And a tile of slack on top, at every pitch. The cut's south edge would
|
|
-- otherwise land on the frame's own bottom row at the rungs where the term
|
|
-- below is zero, which is a hard edge (see FADE_FRAC) balanced on the
|
|
-- pixel it is drawn at -- a supersampled frame (lib/AntiAlias) resolves
|
|
-- half of it. One tile is cheap and no cut this file makes should be
|
|
-- decided by a rounding.
|
|
ViewBox.SOUTH_PAD = 16
|
|
|
|
-- How much further south than the ground it lands on the bottom edge of the
|
|
-- frame can still show, in world pixels.
|
|
--
|
|
-- At sy = -1 the ray direction (see footprint) is
|
|
--
|
|
-- d = (0, -(cos a + tanY sin a), -(sin a - tanY cos a))
|
|
--
|
|
-- in (x, y, z) with y up and -z north, so climbing it costs
|
|
--
|
|
-- (sin a - tanY cos a) / (cos a + tanY sin a)
|
|
--
|
|
-- of south per world pixel of height. Zero at and below atan(tanY) -- about
|
|
-- 26 degrees with FOCAL 1, where the ray is shallower than the frame's own
|
|
-- half-angle and a RAISED point lands north of the ground hit, which no cut
|
|
-- can lose -- a tile and a half at 35, four at 50, and a good eleven at 75,
|
|
-- where the eye is nearly level and a tree is nearly all of what is under
|
|
-- the bottom of the frame.
|
|
function ViewBox.lift(a)
|
|
local Voxel = V.require("VoxelState")
|
|
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
|
|
local ca = math.max(math.cos(a or 0), 1e-3)
|
|
local sa = math.max(math.sin(a or 0), 0)
|
|
local rise = (sa - tanY * ca) / (ca + tanY * sa)
|
|
if rise <= 0 then return 0 end
|
|
return ViewBox.HEIGHT * rise
|
|
end
|
|
|
|
-- The rim under V-CURVE, as a fraction of the shorter half-extent, and for
|
|
-- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides,
|
|
-- so a hard edge across one is a lie about what is being looked at. Flat,
|
|
-- the box keeps its hard edge -- that IS the sides.
|
|
ViewBox.FADE_FRAC = 0.16
|
|
|
|
-- How far outside the box a map may still have geometry inside it: the
|
|
-- border ring ChunkMesher meshes around a body (RING = 3 blocks of 32
|
|
-- world pixels), which is the one thing a map draws beyond its own
|
|
-- rectangle. A neighbour kept by this margin that turns out to be entirely
|
|
-- outside is drawn and then cut per fragment, which is what would have
|
|
-- happened without the test -- the margin can only cost a draw, never a
|
|
-- hole.
|
|
ViewBox.PAD = 96
|
|
|
|
function ViewBox.level()
|
|
return ViewBox.setting:get() or 0
|
|
end
|
|
|
|
-- The multiplier in force, or nil for OFF -- which is also every caller's
|
|
-- "there is no cut this frame" answer.
|
|
function ViewBox.frac()
|
|
local f = ViewBox.FRACS[ViewBox.level() + 1]
|
|
if not f or f <= 0 then return nil end
|
|
return f
|
|
end
|
|
|
|
-- Whether this rung is one the box is about: an ORBIT rung, which is every
|
|
-- level the mode has except OFF (level 0, where there is no 3D pass to cut)
|
|
-- and the two free-roam rungs (see the header).
|
|
function ViewBox.appliesTo(level)
|
|
local ok, applies = pcall(function()
|
|
local Voxel = V.require("VoxelState")
|
|
local l = level or Voxel.level or 0
|
|
return l > 0 and not Voxel.isFreeCam(l)
|
|
end)
|
|
return ok and applies or false
|
|
end
|
|
|
|
-- ------- what the live frame is
|
|
--
|
|
-- Set by VoxelScene for the length of one flat frame and cleared with it,
|
|
-- exactly as Diorama's is for a headset's. Nothing else writes it, and
|
|
-- every reader -- the shader uniforms, the neighbour skip -- hangs off this
|
|
-- one field being nil or not.
|
|
ViewBox.cull = nil -- { x, y, z, r, rx, rz, invFade, kind }
|
|
|
|
local function curved()
|
|
local ok, on = pcall(function()
|
|
return V.require("WorldCurve").active()
|
|
end)
|
|
return ok and on or false
|
|
end
|
|
|
|
-- How far out of the orbit and into a walking head the rung tween has got,
|
|
-- 0..1. The box opens out by one over what is LEFT of the orbit, so it has
|
|
-- grown past every edge of the frame by the time the eye arrives in the
|
|
-- head and the cut is dropped -- rather than the sides vanishing on the
|
|
-- frame the rung number changed, which is a pop in the middle of a move.
|
|
local function orbitLeft()
|
|
local ok, blend = pcall(function()
|
|
return V.require("FirstPerson").blendEased()
|
|
end)
|
|
if not ok or type(blend) ~= "number" then return 1 end
|
|
return 1 - math.max(0, math.min(1, blend))
|
|
end
|
|
|
|
-- ------- the ground this camera frames
|
|
--
|
|
-- The orbit (Voxel3D.viewProjection's else branch) is: eye at distance
|
|
-- k = FOCAL*vh, pitched `a` off straight down and due south of the focus;
|
|
-- focus on the ground at the view centre; a symmetric frustum whose
|
|
-- half-tangents are tanY = 1/(2*FOCAL) vertically and tanY*(vw/vh)
|
|
-- horizontally. Screen coordinates run sx, sy in [-1, 1] with sy = +1 the
|
|
-- TOP of the frame, which is north.
|
|
--
|
|
-- The ray through a screen point is forward + right*sx*tanX + up*sy*tanY,
|
|
-- and with the orbit's basis (right = +x, forward = (0, -cos a, -sin a),
|
|
-- up = (0, sin a, -cos a)) that comes out as
|
|
--
|
|
-- d = ( sx*tanX, -cos a + sy*tanY*sin a, -sin a - sy*tanY*cos a )
|
|
--
|
|
-- Drop it to the ground plane from an eye at height k*cos a and the whole
|
|
-- trapezoid collapses to ONE denominator,
|
|
--
|
|
-- D(sy) = cos a - sy*tanY*sin a
|
|
--
|
|
-- with (the sin^2 + cos^2 cancels most of the algebra away):
|
|
--
|
|
-- north of centre : (vh/2) * sy / D(sy)
|
|
-- half-width : (vw/2) * cos a / D(sy)
|
|
--
|
|
-- because k*tanY is exactly vh/2 and tanX*k is exactly vw/2, whatever FOCAL
|
|
-- is. At a = 0 both reduce to vh/2 and vw/2 -- the flat window, which is
|
|
-- the case the first cut of this file mistook for all of them.
|
|
--
|
|
-- D shrinks as sy climbs, so BOTH grow toward the top of the frame, and
|
|
-- both blow up where D reaches zero: sy* = cot(a)/tanY, the row the horizon
|
|
-- sits on. Past 63 degrees that row is inside the frame and the answer is
|
|
-- infinite -- which is what `reach` is for.
|
|
--
|
|
-- Returns three DISTANCES from the view centre, all positive: how far the
|
|
-- picture runs north, how far south, and how far to each side.
|
|
function ViewBox.footprint(a, vw, vh, reach)
|
|
local Voxel = V.require("VoxelState")
|
|
local halfW, halfH = (vw or 320) * 0.5, (vh or 288) * 0.5
|
|
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
|
|
-- the orbit never reaches level (75 degrees is the last rung) but a tween
|
|
-- reads a live angle, and a cos of zero is a horizon through the middle
|
|
-- of the frame rather than a number
|
|
local ca = math.max(math.cos(a or 0), 1e-3)
|
|
local sa = math.max(math.sin(a or 0), 0)
|
|
-- The screen row the far edge is taken at: the TOP of the frame, or the
|
|
-- row whose ray lands `reach` out, whichever comes first. Inverting the
|
|
-- north formula for sy gives
|
|
--
|
|
-- sy = reach*cos a / (vh/2 + reach*tanY*sin a)
|
|
--
|
|
-- which is always strictly below the horizon row (it approaches it from
|
|
-- underneath as reach grows), so D below is always positive -- with the
|
|
-- horizon in frame this never even reaches 1 and the clamp is inert.
|
|
local sy = reach * ca / (halfH + reach * tanY * sa)
|
|
if sy > 1 then sy = 1 end
|
|
local D = ca - sy * tanY * sa
|
|
if D < 1e-3 then D = 1e-3 end
|
|
return halfH * sy / D, -- north
|
|
halfH / (ca + tanY * sa), -- south: the sy = -1 row
|
|
halfW * ca / D -- and the widest row is the far one
|
|
end
|
|
|
|
-- Open the frame's cut: the bounding rectangle of the ground this camera
|
|
-- frames, times the row's multiplier. Returns the cut, or nil when this
|
|
-- frame has none -- which is the row at OFF, a rung the box is not about,
|
|
-- and a camera that has finished its dive into a head.
|
|
function ViewBox.frame(cx, cy, vw, vh, level)
|
|
ViewBox.cull = nil
|
|
local frac = ViewBox.frac()
|
|
if not (frac and ViewBox.appliesTo(level)) then return nil end
|
|
local left = orbitLeft()
|
|
if left <= 0.001 then return nil end
|
|
frac = frac / left
|
|
local Voxel = V.require("VoxelState")
|
|
local angle = Voxel.angle or 0
|
|
local north, south, side = ViewBox.footprint(
|
|
angle, vw, vh, ViewBox.MAX_REACH * (vh or 288))
|
|
-- the ground the bottom edge lands on is not the southernmost thing under
|
|
-- it: what STANDS there reaches into the frame from further south (see
|
|
-- lift). Added before the row's multiplier, so FIT carries it too -- it is
|
|
-- a correction to the honest answer, not margin around it.
|
|
south = south + ViewBox.lift(angle) + ViewBox.SOUTH_PAD
|
|
north, south, side = north * frac, south * frac, side * frac
|
|
-- The rectangle around it. Off-centre in z, because the trapezoid is:
|
|
-- the camera looks NORTH from south of its focus, so there is far more
|
|
-- picture ahead of the view centre than behind it -- at 35 degrees
|
|
-- roughly twice as much, at 75 the whole frame.
|
|
--
|
|
-- The same floor Diorama.radius keeps: a box smaller than a couple of
|
|
-- tiles is not a viewport, it is a hole the player is standing in.
|
|
local rx = math.max(24, side)
|
|
local rz = math.max(24, (north + south) * 0.5)
|
|
local bent = curved()
|
|
local fade = bent and math.max(1, math.min(rx, rz) * ViewBox.FADE_FRAC) or 0
|
|
ViewBox.cull = {
|
|
x = cx, y = 0, z = cy - (north - south) * 0.5,
|
|
-- `r` is what the ball and the pillar kinds are sized by and the box
|
|
-- is not; carried so the cut table has one shape whoever made it
|
|
r = math.max(rx, rz), rx = rx, rz = rz,
|
|
-- a zero band is a hard edge: half a pixel of ramp, which is one pixel
|
|
-- of antialiasing rather than a stair (Diorama says the same)
|
|
invFade = 1 / math.max(fade, 0.5),
|
|
kind = V.require("Diorama").BOX,
|
|
}
|
|
return ViewBox.cull
|
|
end
|
|
|
|
function ViewBox.stop()
|
|
ViewBox.cull = nil
|
|
end
|
|
|
|
-- ------- the coarse half of the cut
|
|
--
|
|
-- Whether anything inside the world-pixel rectangle (x0, z0)-(x1, z1) can
|
|
-- be inside this frame's box. True whenever there is no box, so a caller
|
|
-- may guard every draw with it unconditionally.
|
|
function ViewBox.shows(x0, z0, x1, z1)
|
|
local c = ViewBox.cull
|
|
if not c then return true end
|
|
local pad = ViewBox.PAD
|
|
return x0 - pad <= c.x + c.rx and x1 + pad >= c.x - c.rx
|
|
and z0 - pad <= c.z + c.rz and z1 + pad >= c.z - c.rz
|
|
end
|
|
|
|
-- The same question about a connected neighbour, in the shape VoxelScene
|
|
-- keeps them: { map, ox, oy } with the offset in world pixels and the map's
|
|
-- own size in blocks of 32 (the shape prefetch's masks are built from).
|
|
function ViewBox.showsMap(nb)
|
|
if not (nb and nb.map and nb.map.def) then return true end
|
|
return ViewBox.shows(nb.ox or 0, nb.oy or 0,
|
|
(nb.ox or 0) + (nb.map.def.width or 0) * 32,
|
|
(nb.oy or 0) + (nb.map.def.height or 0) * 32)
|
|
end
|
|
|
|
-- What the shadow pass has to notice: WHICH neighbours it drew is now a
|
|
-- function of the row, and the row is the one input to that the sun's own
|
|
-- signature does not already carry (the centre, the view size and the
|
|
-- rung are all in it). Widening the box brings a neighbour back into the
|
|
-- light's frustum, and a map recorded without it must be redrawn.
|
|
function ViewBox.signature()
|
|
return ViewBox.frac() or 0
|
|
end
|
|
|
|
function ViewBox.row()
|
|
return ViewBox.setting:row()
|
|
end
|
|
|
|
function ViewBox.sync(value)
|
|
ViewBox.setting:sync(value)
|
|
end
|
|
|
|
return ViewBox
|