From b0878ad5e1d0d5886c13ab3844c3458a17e73c13 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 02:39:20 +0200 Subject: [PATCH] feat: cumulus --- app/level.ts | 88 ++++++++++++++++++++--------------- app/main.ts | 6 +-- engine/render/RenderConfig.ts | 2 +- engine/render/Sky.ts | 87 ++++++++++++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 47 deletions(-) diff --git a/app/level.ts b/app/level.ts index 264cdb4..a4d4e6b 100644 --- a/app/level.ts +++ b/app/level.ts @@ -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) } diff --git a/app/main.ts b/app/main.ts index 40e21c7..ffbf8c6 100644 --- a/app/main.ts +++ b/app/main.ts @@ -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 { 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 { } 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) diff --git a/engine/render/RenderConfig.ts b/engine/render/RenderConfig.ts index a40b62d..d6f5254 100644 --- a/engine/render/RenderConfig.ts +++ b/engine/render/RenderConfig.ts @@ -62,7 +62,7 @@ export type RenderConfig = { * sweep `perspectiveCorrect` 0 -> 0.5 -> 1 so you can watch the texture swim * straighten out as you press through them. */ export namespace RenderConfig { - export const psxish: RenderConfig = { + export const standard: RenderConfig = { internalWidth: 384, internalHeight: 216, upscaleFilter: "nearest", diff --git a/engine/render/Sky.ts b/engine/render/Sky.ts index a0f9fa0..6582def 100644 --- a/engine/render/Sky.ts +++ b/engine/render/Sky.ts @@ -3,7 +3,21 @@ import type { Framebuffer } from "./Framebuffer" import { Camera } from "../scene/Camera" import { Vec3 } from "../math/Vec3" -/** Procedural sky: a vertical gradient plus a sun disc. No texture needed. */ +/** One procedural cloud layer. For now a single cumulus type; add more kinds + * later by giving this a `kind` field and branching in the cloud shader. */ +export type CloudLayer = { + color: Color + /** Roughly the fraction of sky covered, 0..1. */ + coverage: number + /** Puff size: larger = smaller, busier clouds. */ + scale: number + /** Scroll speed (wind), in noise units per second. */ + speed: number + /** Edge softness: small = crisp cumulus rims, large = hazy. */ + edge: number +} + +/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds. */ export type SkyConfig = { zenith: Color horizon: Color @@ -12,6 +26,7 @@ export type SkyConfig = { sunDir: Vec3 /** Angular radius of the sun's core, in radians. */ sunSize: number + clouds: CloudLayer | null } const UP: Vec3 = { x: 0, y: 1, z: 0 } @@ -20,13 +35,13 @@ export namespace Sky { /** * Fill the whole framebuffer with the sky and reset depth to 0. Run first each * frame in place of Framebuffer.clear; opaque geometry then overwrites the sky - * wherever it is nearer. + * wherever it is nearer. `time` (seconds) drives cloud motion. * * Per pixel it reconstructs the view ray from the camera basis, shades a - * horizon->zenith gradient by the ray's elevation (so it pans with pitch and - * yaw), and brightens toward `sun` where the ray points near `sunDir`. + * horizon->zenith gradient by the ray's elevation, brightens toward `sun` near + * `sunDir`, then lays crisp-edged cumulus over the top. */ - export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig): void { + export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number): void { const { width, height, color, depth } = fb const forward = Camera.forward(camera) const right = Vec3.normalize(Vec3.cross(forward, UP)) @@ -35,6 +50,7 @@ export namespace Sky { const tanX = tanY * (width / height) const sun = Vec3.normalize(sky.sunDir) const cosSun = Math.cos(sky.sunSize) + const clouds = sky.clouds for (let y = 0; y < height; y++) { const ndcY = 1 - ((y + 0.5) / height) * 2 for (let x = 0; x < width; x++) { @@ -55,10 +71,71 @@ export namespace Sky { const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5) c = Color.lerp(c, sky.sun, glow) } + if (clouds !== null && dy > 0.02) { + const cover = cumulus(dx, dy, dz, clouds, time) + if (cover > 0) { + c = Color.lerp(c, clouds.color, cover) + } + } const i = y * width + x color[i] = c depth[i] = 0 } } } + + /** + * Coverage in 0..1 of a cumulus layer along a view ray. The ray is projected + * onto a flat cloud plane "at infinity" (xz / y), scrolled by wind, sampled + * with fractal noise, then hard-thresholded so the clouds have distinct puffy + * edges rather than a foggy falloff. Fades out near the horizon, where the + * projection blows up into noise. + */ + function cumulus(dx: number, dy: number, dz: number, layer: CloudLayer, time: number): number { + const u = (dx / dy) * layer.scale + time * layer.speed + const v = (dz / dy) * layer.scale + const density = fbm(u, v) + const threshold = 0.72 - layer.coverage * 0.4 + const cover = smoothstep(threshold - layer.edge, threshold + layer.edge, density) + return cover * smoothstep(0.02, 0.22, dy) + } + + /** Fractal (value-noise) sum, ~0..1, giving lumpy cumulus shapes. */ + function fbm(x: number, y: number): number { + let sum = 0 + let amplitude = 0.5 + let frequency = 1 + for (let octave = 0; octave < 4; octave++) { + sum += amplitude * valueNoise(x * frequency, y * frequency) + frequency *= 2 + amplitude *= 0.5 + } + return sum + } + + function valueNoise(x: number, y: number): number { + const xi = Math.floor(x) + const yi = Math.floor(y) + const xf = x - xi + const yf = y - yi + const u = xf * xf * (3 - 2 * xf) + const v = yf * yf * (3 - 2 * yf) + const a = hash(xi, yi) + const b = hash(xi + 1, yi) + const c = hash(xi, yi + 1) + const d = hash(xi + 1, yi + 1) + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v + } + + /** Deterministic 0..1 hash of an integer lattice point. */ + function hash(x: number, y: number): number { + let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263)) | 0 + h = Math.imul(h ^ (h >>> 13), 1274126177) + return ((h ^ (h >>> 16)) >>> 0) / 4294967295 + } + + function smoothstep(a: number, b: number, x: number): number { + const t = Math.max(0, Math.min(1, (x - a) / (b - a || 1e-4))) + return t * t * (3 - 2 * t) + } }