feat: cumulus

This commit is contained in:
Dan Finch 2026-08-04 02:39:20 +02:00
parent fb89263930
commit b0878ad5e1
4 changed files with 136 additions and 47 deletions

View file

@ -1,6 +1,6 @@
import { Color } from "../engine/render/Color"
import type { SkyConfig } from "../engine/render/Sky"
import type { Mesh, Vertex } from "../engine/scene/Mesh"
import type { Mesh } from "../engine/scene/Mesh"
type Corner = [number, number, number]
@ -27,20 +27,30 @@ export type Level = {
}
const ARENA = 12
const WALL_HEIGHT = 5
const WALL_HEIGHT = 4
const CRATE = { x: -2, z: -2, half: 1, top: 1 }
/** Triangle density: grid divisions per world unit, applied to every textured
* surface (floor, walls, crate). Higher = smaller triangles = each spans less
* depth = less affine texture swim, at the cost of more geometry; lower =
* chunkier, wilder PS1 warp (below ~0.5 the floor tips into the black-wedge
* degeneration). Because it scales with surface size, one value keeps the big
* floor and the little crate equally warp-free. Edit and Vite reloads. Pairs
* with the `ps1` preset's `perspectiveCorrect`, which fights the same error
* from the render side. */
const DIVISIONS_PER_UNIT = 1.3
export function buildLevel(): Level {
const floor = mesh()
quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12, 16)
quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12)
const walls = mesh()
const h = WALL_HEIGHT
// Inward-facing perimeter, no ceiling so the sky shows above.
quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5, 12)
quadGrid(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5, 12)
quadGrid(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5, 12)
quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5, 12)
quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5)
quadGrid(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5)
quadGrid(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5)
quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5)
const crate = mesh()
box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top)
@ -66,6 +76,13 @@ export function buildLevel(): Level {
sun: Color.rgb(255, 246, 214),
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
sunSize: 0.04,
clouds: {
color: Color.rgb(248, 250, 255),
coverage: 0.5,
scale: 0.9,
speed: 0.5,
edge: 0.02,
},
}
return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky }
@ -79,15 +96,18 @@ function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb {
return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false }
}
/** A quad tessellated into an n*n grid so affine texture warp stays per-tile.
* Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number, n: number): void {
/** Tessellate a quad into a grid sized by DIVISIONS_PER_UNIT, so triangle size
* (and thus affine warp) is consistent whatever the surface's scale. Corners
* run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void {
const nu = divisions(a, b)
const nv = divisions(a, d)
const base = m.vertices.length
const row = n + 1
for (let i = 0; i <= n; i++) {
const t = i / n
for (let j = 0; j <= n; j++) {
const s = j / n
const row = nu + 1
for (let i = 0; i <= nv; i++) {
const t = i / nv
for (let j = 0; j <= nu; j++) {
const s = j / nu
const wa = (1 - s) * (1 - t)
const wb = s * (1 - t)
const wc = s * t
@ -102,39 +122,31 @@ function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: numbe
})
}
}
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
for (let i = 0; i < nv; i++) {
for (let j = 0; j < nu; j++) {
const p = base + i * row + j
m.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
}
}
}
/** Grid divisions along edge a->b, from its world length and the density. */
function divisions(a: Corner, b: Corner): number {
const length = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])
return Math.max(1, Math.round(length * DIVISIONS_PER_UNIT))
}
/** A box centered at (cx, cz) on the floor: top face plus four sides, one uv
* tile per face. No bottom (never seen). */
* tile per face. No bottom (never seen). Tessellated like every other surface,
* so it no longer warps up close. */
function box(m: Mesh, cx: number, cz: number, half: number, top: number): void {
const x0 = cx - half
const x1 = cx + half
const z0 = cz - half
const z1 = cz + half
quad(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1])
quad(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0])
quad(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1])
quad(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0])
quad(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1])
}
function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner): void {
const base = m.vertices.length
const corners: [Corner, [number, number]][] = [
[a, [0, 0]],
[b, [1, 0]],
[c, [1, 1]],
[d, [0, 1]],
]
for (const [pos, uv] of corners) {
const vertex: Vertex = { pos: { x: pos[0], y: pos[1], z: pos[2] }, uv: { x: uv[0], y: uv[1] } }
m.vertices.push(vertex)
}
m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
quadGrid(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1], 1, 1)
quadGrid(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0], 1, 1)
quadGrid(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1], 1, 1)
quadGrid(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0], 1, 1)
quadGrid(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1], 1, 1)
}

View file

@ -15,7 +15,7 @@ const ctx = screen.getContext("2d")!
const back = document.createElement("canvas")
const backCtx = back.getContext("2d")!
let config: RenderConfig = RenderConfig.psxish
let config: RenderConfig = RenderConfig.standard
let fb = Framebuffer.create(1, 1)
let image = new ImageData(1, 1)
@ -54,7 +54,7 @@ async function main(): Promise<void> {
globalThis.addEventListener("keydown", (e) => {
keys.add(e.code)
if (e.code === "Digit1") {
useConfig(RenderConfig.psxish)
useConfig(RenderConfig.standard)
}
if (e.code === "Digit2") {
useConfig(RenderConfig.soft)
@ -95,7 +95,7 @@ async function main(): Promise<void> {
}
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
Sky.render(fb, camera, level.sky)
Sky.render(fb, camera, level.sky, now / 1000)
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)