diff --git a/README.md b/README.md index 95efba7..fa0f23b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,4 @@ # MEAT +- knobs +- toggle clouds: off, basic, fancy diff --git a/app/level.ts b/app/level.ts index a4d4e6b..8e5b50b 100644 --- a/app/level.ts +++ b/app/level.ts @@ -1,5 +1,5 @@ import { Color } from "../engine/render/Color" -import type { SkyConfig } from "../engine/render/Sky" +import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { Mesh } from "../engine/scene/Mesh" type Corner = [number, number, number] @@ -40,6 +40,29 @@ const CRATE = { x: -2, z: -2, half: 1, top: 1 } * from the render side. */ const DIVISIONS_PER_UNIT = 1.3 +/** The two cloud styles; swap which one the sky uses in `buildLevel`. + * `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier + * heightfield-shaded, domain-warped version with faked volume. */ +export const basicCumulus: CloudLayer = { + kind: "basic", + color: Color.rgb(248, 250, 255), + coverage: 0.5, + scale: 0.9, + speed: 0.5, + edge: 0.02, +} + +export const fancyCumulus: CloudLayer = { + kind: "fancy", + color: Color.rgb(250, 251, 255), + coverage: 0.5, + scale: 0.6, + speed: 0.5, + edge: 0.02, + warp: 0.4, + relief: 7, +} + export function buildLevel(): Level { const floor = mesh() quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12) @@ -76,13 +99,7 @@ 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, - }, + clouds: fancyCumulus, } return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky } diff --git a/app/main.ts b/app/main.ts index ffbf8c6..c72ba1d 100644 --- a/app/main.ts +++ b/app/main.ts @@ -14,6 +14,7 @@ const screen = document.querySelector("#screen")! const ctx = screen.getContext("2d")! const back = document.createElement("canvas") const backCtx = back.getContext("2d")! +const fpsEl = document.querySelector("#fps")! let config: RenderConfig = RenderConfig.standard let fb = Framebuffer.create(1, 1) @@ -82,9 +83,17 @@ async function main(): Promise { resize() let last = performance.now() + let fpsLast = last + let fpsFrames = 0 function frame(now: number): void { const dt = Math.min(0.05, (now - last) / 1000) last = now + fpsFrames++ + if (now - fpsLast >= 250) { + fpsEl.textContent = `${Math.round((fpsFrames * 1000) / (now - fpsLast))} fps` + fpsLast = now + fpsFrames = 0 + } Player.update(player, keys, dt, level) const camera: Camera = { diff --git a/engine/render/Sky.ts b/engine/render/Sky.ts index 6582def..dd44715 100644 --- a/engine/render/Sky.ts +++ b/engine/render/Sky.ts @@ -3,9 +3,8 @@ import type { Framebuffer } from "./Framebuffer" import { Camera } from "../scene/Camera" import { Vec3 } from "../math/Vec3" -/** 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 = { +/** Fields shared by every cumulus style. */ +export type CumulusBase = { color: Color /** Roughly the fraction of sky covered, 0..1. */ coverage: number @@ -17,6 +16,25 @@ export type CloudLayer = { edge: number } +/** Flat, hard-thresholded white cumulus. Cheap: one noise lookup per pixel. */ +export type BasicCumulus = CumulusBase & { kind: "basic" } + +/** Domain-warped, heightfield-shaded cumulus with faked volume. Pricier + * (~5 noise lookups per pixel) but reads as billowing 3D puffs. */ +export type FancyCumulus = CumulusBase & { + kind: "fancy" + /** Domain-warp amount: bends the noise into bulbous, cauliflower puffs. + * 0 = round blobs, higher = more billowing. */ + warp: number + /** Fake vertical relief for lighting: 0 = flat, higher = deeper, more + * three-dimensional bulges (bright sun-side, shaded underside). */ + relief: number +} + +/** One procedural cloud layer. Add more styles by extending this union and + * branching on `kind` in the cloud shader. */ +export type CloudLayer = BasicCumulus | FancyCumulus + /** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds. */ export type SkyConfig = { zenith: Color @@ -51,6 +69,7 @@ export namespace Sky { const sun = Vec3.normalize(sky.sunDir) const cosSun = Math.cos(sky.sunSize) const clouds = sky.clouds + const cloud: CloudSample = { cover: 0, shade: 1 } for (let y = 0; y < height; y++) { const ndcY = 1 - ((y + 0.5) / height) * 2 for (let x = 0; x < width; x++) { @@ -72,9 +91,13 @@ export namespace Sky { 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) + if (clouds.kind === "fancy") { + fancyCumulus(dx, dy, dz, clouds, time, sun, cloud) + } else { + basicCumulus(dx, dy, dz, clouds, time, cloud) + } + if (cloud.cover > 0) { + c = Color.lerp(c, Color.scale(clouds.color, cloud.shade), cloud.cover) } } const i = y * width + x @@ -84,20 +107,51 @@ export namespace Sky { } } - /** - * 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 { + /** Reusable per-pixel cloud result, to avoid allocating in the sky loop. */ + type CloudSample = { cover: number; shade: number } + + /** Hard-threshold a noise density into cloud coverage (distinct puffy edges), + * then fade it out near the horizon where the cloud-plane projection breaks + * down. Shared by both cumulus styles. */ + function coverage(dy: number, layer: CumulusBase, density: number): number { + const threshold = 0.72 - layer.coverage * 0.4 + return smoothstep(threshold - layer.edge, threshold + layer.edge, density) * smoothstep(0.02, 0.22, dy) + } + + /** basicCumulus -- flat, hard-thresholded white puffs, no lighting. Cheap: + * one noise lookup per pixel. `shade` stays 1 (uniform white). */ + function basicCumulus(dx: number, dy: number, dz: number, layer: CloudLayer, time: number, out: CloudSample): void { 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) + out.cover = coverage(dy, layer, fbm(u, v)) + out.shade = 1 + } + + /** fancyCumulus -- domain-warped clouds with faked volume. The noise doubles + * as a heightfield whose gradient is a fake surface normal, lit so up-facing + * tops read bright and steep bulge sides shade into shadow (the sun picks the + * lit side). ~5 noise lookups per pixel, so noticeably pricier. */ + function fancyCumulus(dx: number, dy: number, dz: number, layer: FancyCumulus, time: number, sun: Vec3, out: CloudSample): void { + const u = (dx / dy) * layer.scale + time * layer.speed + const v = (dz / dy) * layer.scale + // Domain warp: nudge the sample point by another noise field for bulges. + const wu = u + layer.warp * fbm(u * 0.5 + 5.2, v * 0.5 + 1.3) + const wv = v + layer.warp * fbm(u * 0.5 + 9.1, v * 0.5 + 4.7) + const density = fbm(wu, wv) + out.cover = coverage(dy, layer, density) + if (out.cover <= 0) { + return + } + // Treat density as height; its gradient is a fake surface normal (nx, ny, 1). + const e = 0.15 + const nx = -(fbm(wu + e, wv) - density) * layer.relief + const ny = -(fbm(wu, wv + e) - density) * layer.relief + const inv = 1 / Math.hypot(nx, ny, 1) + // Up-facing tops read bright; steep bulge sides fall into shadow, and the + // sun (mapped x -> u, z -> v, y -> up) picks out the lit side. + const up = inv + const sunFace = Math.max(0, (nx * sun.x + ny * sun.z + sun.y) * inv) + out.shade = Math.min(1, 0.4 + 0.35 * up + 0.35 * sunFace) } /** Fractal (value-noise) sum, ~0..1, giving lumpy cumulus shapes. */ diff --git a/index.html b/index.html index f14a726..432d1d2 100644 --- a/index.html +++ b/index.html @@ -16,10 +16,22 @@ display: block; image-rendering: pixelated; } + #fps { + position: fixed; + right: 6px; + bottom: 6px; + font: 16px monospace; + color: #fff; + background: #000; + opacity: 0.5; + padding: 1px 5px; + pointer-events: none; + } +
-- fps