213 lines
8.7 KiB
TypeScript
213 lines
8.7 KiB
TypeScript
import { Color } from "./Color"
|
|
import type { Framebuffer } from "./Framebuffer"
|
|
import { Camera } from "../scene/Camera"
|
|
import { Vec3 } from "../math/Vec3"
|
|
|
|
/** Fields shared by every cumulus style. */
|
|
export type CumulusBase = {
|
|
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
|
|
}
|
|
|
|
/** 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
|
|
horizon: Color
|
|
sun: Color
|
|
/** World-space direction toward the sun (need not be normalized). */
|
|
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 }
|
|
|
|
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. `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, brightens toward `sun` near
|
|
* `sunDir`, then lays crisp-edged cumulus over the top.
|
|
*
|
|
* `step` (>= 1) renders the sky at 1/step resolution: the expensive shading
|
|
* (the per-pixel cloud fbm dominates the frame) runs once per step x step
|
|
* block and is copied across it. The sky is low-frequency, so 2 is nearly free
|
|
* visually and quarters the cloud cost; 1 is full resolution.
|
|
*/
|
|
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
|
|
const { width, height, color, depth } = fb
|
|
const bottom = y1 < 0 ? height : y1
|
|
const forward = Camera.forward(camera)
|
|
const right = Vec3.normalize(Vec3.cross(forward, UP))
|
|
const up = Vec3.cross(right, forward)
|
|
const tanY = Math.tan(camera.fov / 2)
|
|
const tanX = tanY * (width / height)
|
|
const sun = Vec3.normalize(sky.sunDir)
|
|
const cosSun = Math.cos(sky.sunSize)
|
|
const clouds = sky.clouds
|
|
const cloud: CloudSample = { cover: 0, shade: 1 }
|
|
const s = Math.max(1, step | 0)
|
|
// Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block
|
|
// grid stays global and neighboring bands don't seam.
|
|
for (let by = y0; by < bottom; by += s) {
|
|
// Shade at the block center, then flood the whole block with that color.
|
|
const sampleY = Math.min(height - 1, by + (s >> 1))
|
|
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
|
|
const yEnd = Math.min(bottom, by + s)
|
|
for (let bx = 0; bx < width; bx += s) {
|
|
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
|
const ndcX = ((sampleX + 0.5) / width) * 2 - 1
|
|
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
|
|
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
|
|
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
|
|
let dz = forward.z + right.z * ndcX * tanX + up.z * ndcY * tanY
|
|
const inv = 1 / Math.hypot(dx, dy, dz)
|
|
dx *= inv
|
|
dy *= inv
|
|
dz *= inv
|
|
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
|
const t = Math.max(0, Math.min(1, dy))
|
|
let c = Color.lerp(sky.horizon, sky.zenith, t)
|
|
const facing = dx * sun.x + dy * sun.y + dz * sun.z
|
|
if (facing > cosSun) {
|
|
const glow = Math.min(1, ((facing - cosSun) / (1 - cosSun)) * 1.5)
|
|
c = Color.lerp(c, sky.sun, glow)
|
|
}
|
|
if (clouds !== null && dy > 0.02) {
|
|
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 xEnd = Math.min(width, bx + s)
|
|
for (let y = by; y < yEnd; y++) {
|
|
const o = y * width
|
|
for (let x = bx; x < xEnd; x++) {
|
|
color[o + x] = c
|
|
depth[o + x] = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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
|
|
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. */
|
|
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)
|
|
}
|
|
}
|