feat: cumulus
This commit is contained in:
parent
fb89263930
commit
b0878ad5e1
4 changed files with 136 additions and 47 deletions
88
app/level.ts
88
app/level.ts
|
|
@ -1,6 +1,6 @@
|
||||||
import { Color } from "../engine/render/Color"
|
import { Color } from "../engine/render/Color"
|
||||||
import type { SkyConfig } from "../engine/render/Sky"
|
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]
|
type Corner = [number, number, number]
|
||||||
|
|
||||||
|
|
@ -27,20 +27,30 @@ export type Level = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ARENA = 12
|
const ARENA = 12
|
||||||
const WALL_HEIGHT = 5
|
const WALL_HEIGHT = 4
|
||||||
const CRATE = { x: -2, z: -2, half: 1, top: 1 }
|
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 {
|
export function buildLevel(): Level {
|
||||||
const floor = mesh()
|
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 walls = mesh()
|
||||||
const h = WALL_HEIGHT
|
const h = WALL_HEIGHT
|
||||||
// Inward-facing perimeter, no ceiling so the sky shows above.
|
// 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)
|
||||||
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, 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, 12)
|
quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5)
|
||||||
|
|
||||||
const crate = mesh()
|
const crate = mesh()
|
||||||
box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top)
|
box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top)
|
||||||
|
|
@ -66,6 +76,13 @@ export function buildLevel(): Level {
|
||||||
sun: Color.rgb(255, 246, 214),
|
sun: Color.rgb(255, 246, 214),
|
||||||
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
sunDir: { x: 0.3, y: 0.5, z: -0.8 },
|
||||||
sunSize: 0.04,
|
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 }
|
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 }
|
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.
|
/** Tessellate a quad into a grid sized by DIVISIONS_PER_UNIT, so triangle size
|
||||||
* Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */
|
* (and thus affine warp) is consistent whatever the surface's scale. Corners
|
||||||
function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number, n: number): void {
|
* 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 base = m.vertices.length
|
||||||
const row = n + 1
|
const row = nu + 1
|
||||||
for (let i = 0; i <= n; i++) {
|
for (let i = 0; i <= nv; i++) {
|
||||||
const t = i / n
|
const t = i / nv
|
||||||
for (let j = 0; j <= n; j++) {
|
for (let j = 0; j <= nu; j++) {
|
||||||
const s = j / n
|
const s = j / nu
|
||||||
const wa = (1 - s) * (1 - t)
|
const wa = (1 - s) * (1 - t)
|
||||||
const wb = s * (1 - t)
|
const wb = s * (1 - t)
|
||||||
const wc = s * 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 i = 0; i < nv; i++) {
|
||||||
for (let j = 0; j < n; j++) {
|
for (let j = 0; j < nu; j++) {
|
||||||
const p = base + i * row + j
|
const p = base + i * row + j
|
||||||
m.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
|
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
|
/** 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 {
|
function box(m: Mesh, cx: number, cz: number, half: number, top: number): void {
|
||||||
const x0 = cx - half
|
const x0 = cx - half
|
||||||
const x1 = cx + half
|
const x1 = cx + half
|
||||||
const z0 = cz - half
|
const z0 = cz - half
|
||||||
const z1 = cz + half
|
const z1 = cz + half
|
||||||
quad(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1])
|
quadGrid(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1], 1, 1)
|
||||||
quad(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0])
|
quadGrid(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0], 1, 1)
|
||||||
quad(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1])
|
quadGrid(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1], 1, 1)
|
||||||
quad(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0])
|
quadGrid(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0], 1, 1)
|
||||||
quad(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1])
|
quadGrid(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1], 1, 1)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const ctx = screen.getContext("2d")!
|
||||||
const back = document.createElement("canvas")
|
const back = document.createElement("canvas")
|
||||||
const backCtx = back.getContext("2d")!
|
const backCtx = back.getContext("2d")!
|
||||||
|
|
||||||
let config: RenderConfig = RenderConfig.psxish
|
let config: RenderConfig = RenderConfig.standard
|
||||||
let fb = Framebuffer.create(1, 1)
|
let fb = Framebuffer.create(1, 1)
|
||||||
let image = new ImageData(1, 1)
|
let image = new ImageData(1, 1)
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ async function main(): Promise<void> {
|
||||||
globalThis.addEventListener("keydown", (e) => {
|
globalThis.addEventListener("keydown", (e) => {
|
||||||
keys.add(e.code)
|
keys.add(e.code)
|
||||||
if (e.code === "Digit1") {
|
if (e.code === "Digit1") {
|
||||||
useConfig(RenderConfig.psxish)
|
useConfig(RenderConfig.standard)
|
||||||
}
|
}
|
||||||
if (e.code === "Digit2") {
|
if (e.code === "Digit2") {
|
||||||
useConfig(RenderConfig.soft)
|
useConfig(RenderConfig.soft)
|
||||||
|
|
@ -95,7 +95,7 @@ async function main(): Promise<void> {
|
||||||
}
|
}
|
||||||
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
|
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.floor, textures.floor, viewProj, config)
|
||||||
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
|
||||||
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ export type RenderConfig = {
|
||||||
* sweep `perspectiveCorrect` 0 -> 0.5 -> 1 so you can watch the texture swim
|
* sweep `perspectiveCorrect` 0 -> 0.5 -> 1 so you can watch the texture swim
|
||||||
* straighten out as you press through them. */
|
* straighten out as you press through them. */
|
||||||
export namespace RenderConfig {
|
export namespace RenderConfig {
|
||||||
export const psxish: RenderConfig = {
|
export const standard: RenderConfig = {
|
||||||
internalWidth: 384,
|
internalWidth: 384,
|
||||||
internalHeight: 216,
|
internalHeight: 216,
|
||||||
upscaleFilter: "nearest",
|
upscaleFilter: "nearest",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,21 @@ import type { Framebuffer } from "./Framebuffer"
|
||||||
import { Camera } from "../scene/Camera"
|
import { Camera } from "../scene/Camera"
|
||||||
import { Vec3 } from "../math/Vec3"
|
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 = {
|
export type SkyConfig = {
|
||||||
zenith: Color
|
zenith: Color
|
||||||
horizon: Color
|
horizon: Color
|
||||||
|
|
@ -12,6 +26,7 @@ export type SkyConfig = {
|
||||||
sunDir: Vec3
|
sunDir: Vec3
|
||||||
/** Angular radius of the sun's core, in radians. */
|
/** Angular radius of the sun's core, in radians. */
|
||||||
sunSize: number
|
sunSize: number
|
||||||
|
clouds: CloudLayer | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
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
|
* 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
|
* 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
|
* 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
|
* horizon->zenith gradient by the ray's elevation, brightens toward `sun` near
|
||||||
* yaw), and brightens toward `sun` where the ray points near `sunDir`.
|
* `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 { width, height, color, depth } = fb
|
||||||
const forward = Camera.forward(camera)
|
const forward = Camera.forward(camera)
|
||||||
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
||||||
|
|
@ -35,6 +50,7 @@ export namespace Sky {
|
||||||
const tanX = tanY * (width / height)
|
const tanX = tanY * (width / height)
|
||||||
const sun = Vec3.normalize(sky.sunDir)
|
const sun = Vec3.normalize(sky.sunDir)
|
||||||
const cosSun = Math.cos(sky.sunSize)
|
const cosSun = Math.cos(sky.sunSize)
|
||||||
|
const clouds = sky.clouds
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||||
for (let x = 0; x < width; x++) {
|
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)
|
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
||||||
c = Color.lerp(c, sky.sun, glow)
|
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
|
const i = y * width + x
|
||||||
color[i] = c
|
color[i] = c
|
||||||
depth[i] = 0
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue