feat: fancy clouds, fps meter

This commit is contained in:
Dan Finch 2026-08-04 03:00:58 +02:00
parent b0878ad5e1
commit 2c31bd82bf
5 changed files with 120 additions and 26 deletions

View file

@ -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. */