feat: game/engine split refactor + skybox
This commit is contained in:
parent
581e5892b0
commit
eeedcb8e48
34 changed files with 610 additions and 218 deletions
|
|
@ -2,6 +2,7 @@ import { Color } from "./Color"
|
|||
import type { Framebuffer } from "./Framebuffer"
|
||||
import { Camera } from "../scene/Camera"
|
||||
import { Vec3 } from "../math/Vec3"
|
||||
import { Texture } from "./Texture"
|
||||
|
||||
/** Fields shared by every cumulus style. */
|
||||
export type CumulusBase = {
|
||||
|
|
@ -35,7 +36,18 @@ export type FancyCumulus = CumulusBase & {
|
|||
* branching on `kind` in the cloud shader. */
|
||||
export type CloudLayer = BasicCumulus | FancyCumulus
|
||||
|
||||
/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds. */
|
||||
/** An equirectangular panorama used as the sky's base color in place of the
|
||||
* vertical gradient. The sun glow and clouds still layer over it. Set it on
|
||||
* `SkyConfig.skybox` to switch a level over. */
|
||||
export type Skybox = {
|
||||
texture: Texture
|
||||
/** Azimuth offset in turns (0..1) to spin the panorama to taste. Default 0. */
|
||||
yaw?: number
|
||||
}
|
||||
|
||||
/** Procedural sky: a vertical gradient, a sun disc, and optional moving clouds.
|
||||
* If `skybox` is set, the panorama replaces the gradient base while the sun and
|
||||
* clouds still draw over it. */
|
||||
export type SkyConfig = {
|
||||
zenith: Color
|
||||
horizon: Color
|
||||
|
|
@ -45,9 +57,17 @@ export type SkyConfig = {
|
|||
/** Angular radius of the sun's core, in radians. */
|
||||
sunSize: number
|
||||
clouds: CloudLayer | null
|
||||
/** Optional equirectangular panorama; when present, replaces the gradient
|
||||
* base color (sun + clouds still layer over it). */
|
||||
skybox?: Skybox
|
||||
}
|
||||
|
||||
const UP: Vec3 = { x: 0, y: 1, z: 0 }
|
||||
const INV_TAU = 1 / (2 * Math.PI)
|
||||
const INV_PI = 1 / Math.PI
|
||||
/** Keep equirect V a hair off the exact poles: Texture wraps V, so a ray pointing
|
||||
* straight up/down would otherwise blend the panorama's top row into its bottom. */
|
||||
const POLE_EPS = 1e-3
|
||||
|
||||
export namespace Sky {
|
||||
/**
|
||||
|
|
@ -55,14 +75,16 @@ export namespace Sky {
|
|||
* 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.
|
||||
* Per pixel it reconstructs the view ray from the camera basis, shades the
|
||||
* base (the equirect panorama if `sky.skybox` is set, else a horizon->zenith
|
||||
* gradient), brightens toward `sun` near `sunDir`, then composites 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.
|
||||
* The base + sun are shaded per pixel so they stay crisp. `step` (>= 1) only
|
||||
* lowers the *cloud* resolution: the cloud fbm dominates the frame, so it is
|
||||
* sampled once per step x step block and composited across it. Clouds are
|
||||
* low-frequency, so 2 is nearly free visually and quarters the cloud cost; 1
|
||||
* is full cloud 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
|
||||
|
|
@ -75,48 +97,78 @@ export namespace Sky {
|
|||
const sun = Vec3.normalize(sky.sunDir)
|
||||
const cosSun = Math.cos(sky.sunSize)
|
||||
const clouds = sky.clouds
|
||||
const skybox = sky.skybox ?? null
|
||||
const skyboxYaw = skybox?.yaw ?? 0
|
||||
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.
|
||||
// The cheap base (skybox/gradient + sun) is shaded per pixel so it stays
|
||||
// crisp; only the pricey cloud fbm is amortized -- sampled once per `s`x`s`
|
||||
// block and composited over every pixel in it. So `step` lowers cloud
|
||||
// resolution, not the whole sky. Bands must be step-aligned (callers ensure
|
||||
// it) so the cloud 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)
|
||||
// Block-center elevation, used only for the shared cloud sample.
|
||||
const sampleY = Math.min(height - 1, by + (s >> 1))
|
||||
const ndcYc = 1 - ((sampleY + 0.5) / height) * 2
|
||||
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)
|
||||
// Sample the clouds once for the block, from the block-center ray.
|
||||
const sampleX = Math.min(width - 1, bx + (s >> 1))
|
||||
const ndcXc = ((sampleX + 0.5) / width) * 2 - 1
|
||||
let cdx = forward.x + right.x * ndcXc * tanX + up.x * ndcYc * tanY
|
||||
let cdy = forward.y + right.y * ndcXc * tanX + up.y * ndcYc * tanY
|
||||
let cdz = forward.z + right.z * ndcXc * tanX + up.z * ndcYc * tanY
|
||||
const cinv = 1 / Math.hypot(cdx, cdy, cdz)
|
||||
cdx *= cinv
|
||||
cdy *= cinv
|
||||
cdz *= cinv
|
||||
cloud.cover = 0
|
||||
cloud.shade = 1
|
||||
if (clouds !== null && cdy > 0.02) {
|
||||
if (clouds.kind === "fancy") {
|
||||
fancyCumulus(cdx, cdy, cdz, clouds, time, sun, cloud)
|
||||
} else {
|
||||
basicCumulus(cdx, cdy, cdz, clouds, time, cloud)
|
||||
}
|
||||
}
|
||||
const cover = cloud.cover
|
||||
const cloudColor = clouds !== null ? Color.scale(clouds.color, cloud.shade) : 0
|
||||
// Per-pixel base: reconstruct this pixel's ray, shade the panorama (or
|
||||
// gradient) + sun, then composite the block's shared cloud on top.
|
||||
for (let y = by; y < yEnd; y++) {
|
||||
const ndcY = 1 - ((y + 0.5) / height) * 2
|
||||
const o = y * width
|
||||
for (let x = bx; x < xEnd; x++) {
|
||||
const ndcX = ((x + 0.5) / width) * 2 - 1
|
||||
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
|
||||
let c: Color
|
||||
if (skybox !== null) {
|
||||
// Equirectangular lookup: azimuth -> u (wraps at the seam, which
|
||||
// Texture.sample handles), elevation -> v, clamped off the poles
|
||||
// (Texture also wraps V, which would smear top into bottom).
|
||||
const u = Math.atan2(dx, -dz) * INV_TAU + 0.5 + skyboxYaw
|
||||
const lat = Math.acos(Math.max(-1, Math.min(1, dy))) * INV_PI
|
||||
const v = Math.min(1 - POLE_EPS, Math.max(POLE_EPS, lat))
|
||||
c = Texture.sample(skybox.texture, u, v, "linear")
|
||||
} else {
|
||||
// dy is the ray elevation: 0 at the horizon, 1 straight up.
|
||||
c = Color.lerp(sky.horizon, sky.zenith, Math.max(0, Math.min(1, dy)))
|
||||
}
|
||||
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 (cover > 0) {
|
||||
c = Color.lerp(c, cloudColor, cover)
|
||||
}
|
||||
color[o + x] = c
|
||||
depth[o + x] = 0
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue