refactor: move world concepts into engine
This commit is contained in:
parent
eeedcb8e48
commit
2d15c7ab8d
52 changed files with 3298 additions and 1558 deletions
|
|
@ -22,7 +22,7 @@ export namespace Mat4 {
|
|||
|
||||
/** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y,
|
||||
* then a translation. Built directly in column-major storage (no intermediate
|
||||
* matmuls) since it runs per mob per frame. A vertex at local +Z ends up
|
||||
* matmuls) since it runs per instance per frame. A vertex at local +Z ends up
|
||||
* pointing along world (sin yaw, 0, cos yaw), i.e. the object faces `yaw`. */
|
||||
export function compose(tx: number, ty: number, tz: number, yaw: number, scale: number): Mat4 {
|
||||
const c = Math.cos(yaw)
|
||||
|
|
|
|||
29
engine/render/Chunk.ts
Normal file
29
engine/render/Chunk.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { DrawGroup } from "./Material"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
|
||||
export type Bounds3 = {
|
||||
minX: number
|
||||
minY: number
|
||||
minZ: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
maxZ: number
|
||||
}
|
||||
|
||||
/** One cullable section of static world geometry with two engine-supported LODs. */
|
||||
export type Chunk = Bounds3 & {
|
||||
readonly near: readonly DrawGroup[]
|
||||
readonly far: readonly DrawGroup[]
|
||||
}
|
||||
|
||||
export namespace Chunk {
|
||||
export function isFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean {
|
||||
if (!(lodDistance < Infinity)) {
|
||||
return false
|
||||
}
|
||||
const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x))
|
||||
const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y))
|
||||
const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z))
|
||||
return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance
|
||||
}
|
||||
}
|
||||
123
engine/render/ChunkBuilder.ts
Normal file
123
engine/render/ChunkBuilder.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { STRIDE, type Mesh } from "../scene/Mesh"
|
||||
import type { Chunk, Bounds3 } from "./Chunk"
|
||||
import type { DrawGroup, Material } from "./Material"
|
||||
|
||||
export type MeshBatch = {
|
||||
mesh: (material: Material) => Mesh
|
||||
use: (material: Material, mesh: Mesh) => void
|
||||
}
|
||||
|
||||
export type ChunkItem = {
|
||||
position: { x: number; z: number }
|
||||
bakeNear: (batch: MeshBatch) => void
|
||||
bakeFar?: (batch: MeshBatch) => void
|
||||
}
|
||||
|
||||
export type ChunkCell = {
|
||||
x0: number
|
||||
z0: number
|
||||
x1: number
|
||||
z1: number
|
||||
}
|
||||
|
||||
export type ChunkBuilder = {
|
||||
minX: number
|
||||
minZ: number
|
||||
maxX: number
|
||||
maxZ: number
|
||||
columns: number
|
||||
rows: number
|
||||
bakeCell: (near: MeshBatch, far: MeshBatch, cell: ChunkCell) => void
|
||||
}
|
||||
|
||||
export namespace ChunkBuilder {
|
||||
export function build(config: ChunkBuilder, items: ChunkItem[]): Chunk[] {
|
||||
const width = (config.maxX - config.minX) / config.columns
|
||||
const depth = (config.maxZ - config.minZ) / config.rows
|
||||
const chunks: Chunk[] = []
|
||||
for (let column = 0; column < config.columns; column++) {
|
||||
const x0 = config.minX + column * width
|
||||
const x1 = x0 + width
|
||||
for (let row = 0; row < config.rows; row++) {
|
||||
const z0 = config.minZ + row * depth
|
||||
const z1 = z0 + depth
|
||||
const nearMeshes = new Map<Material, Mesh>()
|
||||
const farMeshes = new Map<Material, Mesh>()
|
||||
const near = batch(nearMeshes)
|
||||
const far = batch(farMeshes)
|
||||
config.bakeCell(near, far, { x0, z0, x1, z1 })
|
||||
for (const item of items) {
|
||||
if (inCell(item.position, x0, z0, x1, z1)) {
|
||||
item.bakeNear(near)
|
||||
item.bakeFar?.(far)
|
||||
}
|
||||
}
|
||||
const nearGroups = groups(nearMeshes)
|
||||
const farGroups = groups(farMeshes)
|
||||
const box = bounds([...nearMeshes.values(), ...farMeshes.values()])
|
||||
if (box !== null) {
|
||||
chunks.push({ ...box, near: nearGroups, far: farGroups })
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function batch(meshes: Map<Material, Mesh>): MeshBatch {
|
||||
return {
|
||||
mesh(material) {
|
||||
let mesh = meshes.get(material)
|
||||
if (mesh === undefined) {
|
||||
mesh = { verts: [], indices: [] }
|
||||
meshes.set(material, mesh)
|
||||
}
|
||||
return mesh
|
||||
},
|
||||
use(material, mesh) {
|
||||
meshes.set(material, mesh)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function groups(meshes: Map<Material, Mesh>): DrawGroup[] {
|
||||
const result: DrawGroup[] = []
|
||||
for (const [material, mesh] of meshes) {
|
||||
if (mesh.indices.length > 0) {
|
||||
result.push({ mesh, material })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function inCell(
|
||||
position: { x: number; z: number },
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
): boolean {
|
||||
return (
|
||||
position.x >= x0 && position.x < x1 && position.z >= z0 && position.z < z1
|
||||
)
|
||||
}
|
||||
|
||||
function bounds(meshes: Mesh[]): Bounds3 | null {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let minZ = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
let maxZ = -Infinity
|
||||
for (const mesh of meshes) {
|
||||
for (let i = 0; i < mesh.verts.length; i += STRIDE) {
|
||||
minX = Math.min(minX, mesh.verts[i])
|
||||
minY = Math.min(minY, mesh.verts[i + 1])
|
||||
minZ = Math.min(minZ, mesh.verts[i + 2])
|
||||
maxX = Math.max(maxX, mesh.verts[i])
|
||||
maxY = Math.max(maxY, mesh.verts[i + 1])
|
||||
maxZ = Math.max(maxZ, mesh.verts[i + 2])
|
||||
}
|
||||
}
|
||||
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
|
||||
}
|
||||
}
|
||||
|
|
@ -115,8 +115,8 @@ export namespace Rasterizer {
|
|||
* NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or
|
||||
* 4 verts) to `dst` and returning its vertex count.
|
||||
*
|
||||
* This matters even when standing inside the room: a wall to your side has
|
||||
* vertices both in front of and behind the eye. Without clipping, the behind
|
||||
* Geometry intersecting the camera plane has vertices both in front of and
|
||||
* behind the eye. Without clipping, the behind
|
||||
* vertices have w <= 0 and invert under the perspective divide, smearing the
|
||||
* triangle across the whole screen (and risking divide-by-zero).
|
||||
*/
|
||||
|
|
@ -201,7 +201,7 @@ export namespace Rasterizer {
|
|||
return
|
||||
}
|
||||
// Backface cull: a back-facing triangle has positive area here. Only for
|
||||
// solid, consistently-wound meshes; sprites/room stay double-sided.
|
||||
// solid, consistently-wound meshes; other materials may stay double-sided.
|
||||
if (cull && area > 0) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,8 @@ export type RenderConfig = {
|
|||
* short draw distance and the shimmer of far geometry. It also colors pixels
|
||||
* no triangle covers, so the frame's clear color should match `fog.color`. */
|
||||
fog: Fog | null
|
||||
/** Beyond this distance (world units) trees + boulders draw as cheap low-poly
|
||||
* impostors instead of full geometry, cutting per-triangle work in dense
|
||||
* views. Kept inside `fog.far` so far detail is already fog-dimmed at the
|
||||
* switch; `Infinity` disables LOD. */
|
||||
/** Beyond this distance (world units), chunks draw their cheaper far groups
|
||||
* instead of near geometry. `Infinity` disables LOD. */
|
||||
lodDistance: number
|
||||
}
|
||||
|
||||
|
|
|
|||
123
engine/render/RenderProtocol.ts
Normal file
123
engine/render/RenderProtocol.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { Mat4 } from "../math/Mat4"
|
||||
import type { Camera } from "../scene/Camera"
|
||||
import type { RenderConfig } from "./RenderConfig"
|
||||
import type { RenderInstance, RenderScene } from "./RenderScene"
|
||||
|
||||
export type RenderWorkerInit = {
|
||||
colorSAB: SharedArrayBuffer
|
||||
depthSAB: SharedArrayBuffer
|
||||
width: number
|
||||
height: number
|
||||
scene: RenderScene
|
||||
band: [number, number]
|
||||
config: RenderConfig
|
||||
skyStep: number
|
||||
ctrlSAB: SharedArrayBuffer
|
||||
cameraSAB: SharedArrayBuffer
|
||||
viewProjectionSAB: SharedArrayBuffer
|
||||
visibleChunkSAB: SharedArrayBuffer
|
||||
instanceIdSAB: SharedArrayBuffer
|
||||
instanceTransformSAB: SharedArrayBuffer
|
||||
timesSAB: SharedArrayBuffer
|
||||
workerIndex: number
|
||||
}
|
||||
|
||||
export type RenderFrameCamera = {
|
||||
camera: Camera
|
||||
time: number
|
||||
}
|
||||
|
||||
/** Shared frame-buffer layout used by browser driver and render workers. */
|
||||
export namespace RenderProtocol {
|
||||
export const FRAME = 0
|
||||
export const DONE = 1
|
||||
export const VISIBLE_CHUNKS = 2
|
||||
export const VISIBLE_INSTANCES = 3
|
||||
export const CONTROL_LENGTH = 4
|
||||
export const CAMERA_X = 0
|
||||
export const CAMERA_Y = 1
|
||||
export const CAMERA_Z = 2
|
||||
export const CAMERA_YAW = 3
|
||||
export const CAMERA_PITCH = 4
|
||||
export const CAMERA_FOV = 5
|
||||
export const CAMERA_TIME = 6
|
||||
export const CAMERA_LENGTH = 7
|
||||
export const VIEW_PROJECTION_LENGTH = 16
|
||||
export const TRANSFORM_FLOATS = 5
|
||||
|
||||
export function writeCamera(
|
||||
output: Float64Array<ArrayBufferLike>,
|
||||
camera: Camera,
|
||||
time: number,
|
||||
): void {
|
||||
output[CAMERA_X] = camera.position.x
|
||||
output[CAMERA_Y] = camera.position.y
|
||||
output[CAMERA_Z] = camera.position.z
|
||||
output[CAMERA_YAW] = camera.yaw
|
||||
output[CAMERA_PITCH] = camera.pitch
|
||||
output[CAMERA_FOV] = camera.fov
|
||||
output[CAMERA_TIME] = time
|
||||
}
|
||||
|
||||
export function readCamera(input: Float64Array<ArrayBufferLike>): RenderFrameCamera {
|
||||
return {
|
||||
camera: {
|
||||
position: {
|
||||
x: input[CAMERA_X],
|
||||
y: input[CAMERA_Y],
|
||||
z: input[CAMERA_Z],
|
||||
},
|
||||
yaw: input[CAMERA_YAW],
|
||||
pitch: input[CAMERA_PITCH],
|
||||
fov: input[CAMERA_FOV],
|
||||
},
|
||||
time: input[CAMERA_TIME],
|
||||
}
|
||||
}
|
||||
|
||||
export function writeViewProjection(
|
||||
output: Float32Array<ArrayBufferLike>,
|
||||
viewProjection: Mat4,
|
||||
): void {
|
||||
output.set(viewProjection)
|
||||
}
|
||||
|
||||
export function writeInstances(
|
||||
ids: Int32Array<ArrayBufferLike>,
|
||||
transforms: Float32Array<ArrayBufferLike>,
|
||||
instances: RenderInstance[],
|
||||
): number {
|
||||
const count = Math.min(instances.length, ids.length)
|
||||
for (let i = 0; i < count; i++) {
|
||||
const instance = instances[i]
|
||||
const offset = i * TRANSFORM_FLOATS
|
||||
ids[i] = instance.prototype
|
||||
transforms[offset] = instance.x
|
||||
transforms[offset + 1] = instance.y
|
||||
transforms[offset + 2] = instance.z
|
||||
transforms[offset + 3] = instance.heading
|
||||
transforms[offset + 4] = instance.scale
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export function readInstances(
|
||||
ids: Int32Array<ArrayBufferLike>,
|
||||
transforms: Float32Array<ArrayBufferLike>,
|
||||
count: number,
|
||||
output: RenderInstance[],
|
||||
): void {
|
||||
output.length = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
const offset = i * TRANSFORM_FLOATS
|
||||
output.push({
|
||||
prototype: ids[i],
|
||||
x: transforms[offset],
|
||||
y: transforms[offset + 1],
|
||||
z: transforms[offset + 2],
|
||||
heading: transforms[offset + 3],
|
||||
scale: transforms[offset + 4],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
224
engine/render/RenderScene.ts
Normal file
224
engine/render/RenderScene.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { Mat4 as Matrix, type Mat4 } from "../math/Mat4"
|
||||
import type { Vec2 } from "../math/Vec2"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
import type { Camera } from "../scene/Camera"
|
||||
import { Sprite } from "../scene/Sprite"
|
||||
import { Framebuffer, type Framebuffer as Frame } from "./Framebuffer"
|
||||
import { Frustum } from "./Frustum"
|
||||
import type { DrawGroup, Material } from "./Material"
|
||||
import { Rasterizer } from "./Rasterizer"
|
||||
import type { RenderConfig } from "./RenderConfig"
|
||||
import { Sky, type SkyConfig } from "./Sky"
|
||||
import { Chunk, type Chunk as RenderChunk } from "./Chunk"
|
||||
|
||||
export type Billboard = {
|
||||
position: Vec3
|
||||
size: Vec2
|
||||
material: Material
|
||||
}
|
||||
|
||||
export type RenderPrototype = {
|
||||
readonly groups: readonly DrawGroup[]
|
||||
readonly radius: number
|
||||
readonly minY: number
|
||||
readonly maxY: number
|
||||
}
|
||||
|
||||
export type RenderTransform = {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
heading: number
|
||||
scale: number
|
||||
}
|
||||
|
||||
export type RenderInstance = RenderTransform & {
|
||||
prototype: number
|
||||
}
|
||||
|
||||
/** Clone-safe render projection of a live level. Contains no behavior callbacks or
|
||||
* game-content registries, so workers need only engine code. */
|
||||
export type RenderScene = {
|
||||
readonly chunks: readonly RenderChunk[]
|
||||
readonly staticGroups: readonly DrawGroup[]
|
||||
readonly billboards: readonly Billboard[]
|
||||
readonly prototypes: readonly RenderPrototype[]
|
||||
readonly maxInstances: number
|
||||
readonly sky: SkyConfig
|
||||
}
|
||||
|
||||
export namespace RenderScene {
|
||||
export function visibleChunks(
|
||||
scene: RenderScene,
|
||||
viewProjection: Mat4,
|
||||
): number[] {
|
||||
const frustum = Frustum.fromViewProj(viewProjection)
|
||||
const visible: number[] = []
|
||||
for (let i = 0; i < scene.chunks.length; i++) {
|
||||
const chunk = scene.chunks[i]
|
||||
if (
|
||||
Frustum.intersectsAabb(
|
||||
frustum,
|
||||
chunk.minX,
|
||||
chunk.minY,
|
||||
chunk.minZ,
|
||||
chunk.maxX,
|
||||
chunk.maxY,
|
||||
chunk.maxZ,
|
||||
)
|
||||
) {
|
||||
visible.push(i)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
export function visibleInstances(
|
||||
scene: RenderScene,
|
||||
instances: RenderInstance[],
|
||||
viewProjection: Mat4,
|
||||
): RenderInstance[] {
|
||||
const frustum = Frustum.fromViewProj(viewProjection)
|
||||
const visible: RenderInstance[] = []
|
||||
for (const instance of instances) {
|
||||
const prototype = scene.prototypes[instance.prototype]
|
||||
if (prototype === undefined) {
|
||||
continue
|
||||
}
|
||||
const radius = prototype.radius * instance.scale
|
||||
if (
|
||||
Frustum.intersectsAabb(
|
||||
frustum,
|
||||
instance.x - radius,
|
||||
instance.y + prototype.minY * instance.scale,
|
||||
instance.z - radius,
|
||||
instance.x + radius,
|
||||
instance.y + prototype.maxY * instance.scale,
|
||||
instance.z + radius,
|
||||
)
|
||||
) {
|
||||
visible.push(instance)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
export function renderBand(
|
||||
framebuffer: Frame,
|
||||
scene: RenderScene,
|
||||
camera: Camera,
|
||||
viewProjection: Mat4,
|
||||
visible: number[],
|
||||
instances: RenderInstance[],
|
||||
config: RenderConfig,
|
||||
skyStep: number,
|
||||
time: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
Sky.render(framebuffer, camera, scene.sky, time, skyStep, y0, y1)
|
||||
drawGroups(framebuffer, scene.staticGroups, viewProjection, config, y0, y1)
|
||||
for (const index of visible) {
|
||||
const chunk = scene.chunks[index]
|
||||
const groups = Chunk.isFar(chunk, camera.position, config.lodDistance)
|
||||
? chunk.far
|
||||
: chunk.near
|
||||
drawGroups(framebuffer, groups, viewProjection, config, y0, y1)
|
||||
}
|
||||
for (const billboard of scene.billboards) {
|
||||
const sprite = {
|
||||
position: billboard.position,
|
||||
size: billboard.size,
|
||||
texture: billboard.material.texture,
|
||||
}
|
||||
Rasterizer.draw(
|
||||
framebuffer,
|
||||
Sprite.billboard(sprite, camera),
|
||||
billboard.material.texture,
|
||||
viewProjection,
|
||||
config,
|
||||
billboard.material.cull,
|
||||
y0,
|
||||
y1,
|
||||
)
|
||||
}
|
||||
for (const instance of instances) {
|
||||
const prototype = scene.prototypes[instance.prototype]
|
||||
if (prototype === undefined) {
|
||||
continue
|
||||
}
|
||||
const modelViewProjection = Matrix.multiply(
|
||||
viewProjection,
|
||||
Matrix.compose(
|
||||
instance.x,
|
||||
instance.y,
|
||||
instance.z,
|
||||
instance.heading,
|
||||
instance.scale,
|
||||
),
|
||||
)
|
||||
drawGroups(
|
||||
framebuffer,
|
||||
prototype.groups,
|
||||
modelViewProjection,
|
||||
config,
|
||||
y0,
|
||||
y1,
|
||||
)
|
||||
}
|
||||
Framebuffer.quantize(framebuffer, config, y0, y1)
|
||||
}
|
||||
|
||||
export function triangleCount(
|
||||
scene: RenderScene,
|
||||
visible: number[],
|
||||
instances: RenderInstance[],
|
||||
eye: Vec3,
|
||||
lodDistance: number,
|
||||
): number {
|
||||
let indices = groupIndices(scene.staticGroups) + scene.billboards.length * 6
|
||||
for (const index of visible) {
|
||||
const chunk = scene.chunks[index]
|
||||
indices += groupIndices(
|
||||
Chunk.isFar(chunk, eye, lodDistance) ? chunk.far : chunk.near,
|
||||
)
|
||||
}
|
||||
for (const instance of instances) {
|
||||
const prototype = scene.prototypes[instance.prototype]
|
||||
if (prototype !== undefined) {
|
||||
indices += groupIndices(prototype.groups)
|
||||
}
|
||||
}
|
||||
return (indices / 3) | 0
|
||||
}
|
||||
|
||||
function drawGroups(
|
||||
framebuffer: Frame,
|
||||
groups: readonly DrawGroup[],
|
||||
matrix: Mat4,
|
||||
config: RenderConfig,
|
||||
y0: number,
|
||||
y1: number,
|
||||
): void {
|
||||
for (const group of groups) {
|
||||
Rasterizer.draw(
|
||||
framebuffer,
|
||||
group.mesh,
|
||||
group.material.texture,
|
||||
matrix,
|
||||
config,
|
||||
group.material.cull,
|
||||
y0,
|
||||
y1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function groupIndices(groups: readonly DrawGroup[]): number {
|
||||
let count = 0
|
||||
for (const group of groups) {
|
||||
count += group.mesh.indices.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,48 @@
|
|||
import type { Mesh } from "./Mesh"
|
||||
import type { RenderPrototype, RenderTransform } from "../render/RenderScene"
|
||||
import type { Collider } from "../world/Collider"
|
||||
|
||||
/** Definition of an **Entity** actor kind: something that lives in the world with
|
||||
* its own behavior and a live transform (mobs, and later the npc / powerups) -- as
|
||||
* opposed to a baked, static `Prop`. `State` is the per-instance runtime record the
|
||||
* behavior mutates; `World` is whatever that behavior reads (e.g. `Terrain`).
|
||||
*
|
||||
* Every field is plain data or a module function, so a definition is **imported
|
||||
* into each context** (main thread + each render worker) rather than structured-
|
||||
* cloned across the wire -- the per-kind polymorphism is code, not serialized
|
||||
* state. That's what lets a registry of these stay compatible with the worker
|
||||
* renderer (only plain instance data ever crosses; behavior is loaded per side). */
|
||||
export type Entity<State, World> = {
|
||||
/** Stable tag for the kind (also the texture key today). The registry's order,
|
||||
* not this string, is what becomes the id packed into the mob SAB. */
|
||||
name: string
|
||||
/** Build the canonical local-space mesh once; every instance shares it, differing
|
||||
* only by its per-frame model matrix. */
|
||||
build: (mesh: Mesh) => void
|
||||
/** Advance one instance by `dt` seconds. */
|
||||
/** Open actor behavior and representation. Concrete definitions are ordinary game
|
||||
* objects referenced directly by instances. */
|
||||
export type ActorDefinition<State, World> = {
|
||||
prototype: RenderPrototype
|
||||
update: (state: State, dt: number, world: World) => void
|
||||
/** Local bounding radius (pre-scale) for the per-frame cull AABB. */
|
||||
boundingRadius: number
|
||||
/** Local body height (pre-scale) for the top of the stand-on collider. */
|
||||
bodyHeight: number
|
||||
transform: (state: State) => RenderTransform
|
||||
collider?: (state: State, world: World) => Collider | null
|
||||
}
|
||||
|
||||
/** Type-erased live actor. `create` captures concrete state safely, allowing one
|
||||
* level to hold unrelated actor state types without a content union. */
|
||||
export type Actor<World> = {
|
||||
readonly definition: object
|
||||
readonly prototype: RenderPrototype
|
||||
readonly updateState: (dt: number, world: World) => void
|
||||
readonly readTransform: () => RenderTransform
|
||||
readonly readCollider: (world: World) => Collider | null
|
||||
}
|
||||
|
||||
export namespace Actor {
|
||||
export function create<State, World>(
|
||||
definition: ActorDefinition<State, World>,
|
||||
state: State,
|
||||
): Actor<World> {
|
||||
return {
|
||||
definition,
|
||||
prototype: definition.prototype,
|
||||
updateState: (dt, world) => definition.update(state, dt, world),
|
||||
readTransform: () => definition.transform(state),
|
||||
readCollider: (world) => definition.collider?.(state, world) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function update<World>(actor: Actor<World>, dt: number, world: World): void {
|
||||
actor.updateState(dt, world)
|
||||
}
|
||||
|
||||
export function transform<World>(actor: Actor<World>): RenderTransform {
|
||||
return actor.readTransform()
|
||||
}
|
||||
|
||||
export function collider<World>(actor: Actor<World>, world: World): Collider | null {
|
||||
return actor.readCollider(world)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
80
engine/scene/MeshBuilder.ts
Normal file
80
engine/scene/MeshBuilder.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { STRIDE, type Mesh } from "./Mesh"
|
||||
|
||||
type Corner = [number, number, number]
|
||||
|
||||
export namespace MeshBuilder {
|
||||
export function quad(
|
||||
mesh: Mesh,
|
||||
a: Corner,
|
||||
b: Corner,
|
||||
c: Corner,
|
||||
d: Corner,
|
||||
uScale: number,
|
||||
vScale: number,
|
||||
): void {
|
||||
const base = mesh.verts.length / STRIDE
|
||||
mesh.verts.push(
|
||||
a[0],
|
||||
a[1],
|
||||
a[2],
|
||||
0,
|
||||
0,
|
||||
b[0],
|
||||
b[1],
|
||||
b[2],
|
||||
uScale,
|
||||
0,
|
||||
c[0],
|
||||
c[1],
|
||||
c[2],
|
||||
uScale,
|
||||
vScale,
|
||||
d[0],
|
||||
d[1],
|
||||
d[2],
|
||||
0,
|
||||
vScale,
|
||||
)
|
||||
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
|
||||
}
|
||||
|
||||
export function slab(
|
||||
mesh: Mesh,
|
||||
x0: number,
|
||||
x1: number,
|
||||
z0: number,
|
||||
z1: number,
|
||||
y0: number,
|
||||
y1: number,
|
||||
tilesPerUnit: number,
|
||||
): void {
|
||||
const dx = (x1 - x0) * tilesPerUnit
|
||||
const dz = (z1 - z0) * tilesPerUnit
|
||||
const dy = (y1 - y0) * tilesPerUnit
|
||||
quad(mesh, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz)
|
||||
quad(mesh, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy)
|
||||
quad(mesh, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy)
|
||||
quad(mesh, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy)
|
||||
quad(mesh, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy)
|
||||
}
|
||||
|
||||
export function box(
|
||||
mesh: Mesh,
|
||||
centerX: number,
|
||||
centerZ: number,
|
||||
half: number,
|
||||
base: number,
|
||||
height: number,
|
||||
): void {
|
||||
const x0 = centerX - half
|
||||
const x1 = centerX + half
|
||||
const z0 = centerZ - half
|
||||
const z1 = centerZ + half
|
||||
const y1 = base + height
|
||||
quad(mesh, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], 1, 1)
|
||||
quad(mesh, [x0, base, z0], [x1, base, z0], [x1, y1, z0], [x0, y1, z0], 1, 1)
|
||||
quad(mesh, [x1, base, z1], [x0, base, z1], [x0, y1, z1], [x1, y1, z1], 1, 1)
|
||||
quad(mesh, [x1, base, z0], [x1, base, z1], [x1, y1, z1], [x1, y1, z0], 1, 1)
|
||||
quad(mesh, [x0, base, z1], [x0, base, z0], [x0, y1, z0], [x0, y1, z1], 1, 1)
|
||||
}
|
||||
}
|
||||
38
engine/scene/Prefab.ts
Normal file
38
engine/scene/Prefab.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
import type { MeshBatch } from "../render/ChunkBuilder"
|
||||
import type { Collider } from "../world/Collider"
|
||||
|
||||
/** Static content recipe. Concrete game prefabs implement this engine contract and
|
||||
* are referenced as objects, never through a closed content-kind registry. */
|
||||
export type Prefab<State> = {
|
||||
position: (state: State) => Vec3
|
||||
bakeNear: (state: State, batch: MeshBatch) => void
|
||||
bakeFar?: (state: State, batch: MeshBatch) => void
|
||||
collider?: (state: State) => Collider | null
|
||||
}
|
||||
|
||||
/** Type-erased placed prefab consumed during level compilation only. */
|
||||
export type PlacedPrefab = {
|
||||
position: Vec3
|
||||
bakeNear: (batch: MeshBatch) => void
|
||||
bakeFar?: (batch: MeshBatch) => void
|
||||
collider: Collider | null
|
||||
}
|
||||
|
||||
export namespace Prefab {
|
||||
export function place<State>(
|
||||
definition: Prefab<State>,
|
||||
state: State,
|
||||
): PlacedPrefab {
|
||||
const bakeFar = definition.bakeFar
|
||||
return {
|
||||
position: definition.position(state),
|
||||
bakeNear: (batch) => definition.bakeNear(state, batch),
|
||||
bakeFar:
|
||||
bakeFar === undefined
|
||||
? undefined
|
||||
: (batch) => bakeFar(state, batch),
|
||||
collider: definition.collider?.(state) ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
89
engine/world/CharacterController.ts
Normal file
89
engine/world/CharacterController.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
import { CollisionWorld, type CollisionWorld as World } from "./CollisionWorld"
|
||||
|
||||
export type Character = {
|
||||
position: Vec3
|
||||
yaw: number
|
||||
velocityY: number
|
||||
onGround: boolean
|
||||
}
|
||||
|
||||
export type CharacterInput = {
|
||||
forward: number
|
||||
right: number
|
||||
jump: boolean
|
||||
run: boolean
|
||||
}
|
||||
|
||||
export type CharacterConfig = {
|
||||
radius: number
|
||||
speed: number
|
||||
runMultiplier: number
|
||||
gravity: number
|
||||
jumpSpeed: number
|
||||
eyeHeight: number
|
||||
}
|
||||
|
||||
export namespace CharacterController {
|
||||
export function update(
|
||||
character: Character,
|
||||
input: CharacterInput,
|
||||
dt: number,
|
||||
world: World,
|
||||
config: CharacterConfig,
|
||||
): void {
|
||||
if (input.jump && character.onGround) {
|
||||
character.velocityY = config.jumpSpeed
|
||||
character.onGround = false
|
||||
}
|
||||
const steps = moveSubsteps(input, dt, config)
|
||||
for (let i = 0; i < steps; i++) {
|
||||
moveHorizontal(character, input, dt / steps, config)
|
||||
CollisionWorld.pushOut(world, character.position, config.radius)
|
||||
}
|
||||
character.velocityY -= config.gravity * dt
|
||||
character.position.y += character.velocityY * dt
|
||||
const ground = CollisionWorld.groundHeight(world, character.position)
|
||||
if (character.position.y <= ground) {
|
||||
character.position.y = ground
|
||||
character.velocityY = 0
|
||||
character.onGround = true
|
||||
} else {
|
||||
character.onGround = false
|
||||
}
|
||||
}
|
||||
|
||||
function moveSubsteps(
|
||||
input: CharacterInput,
|
||||
dt: number,
|
||||
config: CharacterConfig,
|
||||
): number {
|
||||
const distance =
|
||||
config.speed *
|
||||
runFactor(input, config) *
|
||||
dt *
|
||||
Math.hypot(input.forward, input.right)
|
||||
return Math.max(1, Math.ceil(distance / config.radius))
|
||||
}
|
||||
|
||||
function moveHorizontal(
|
||||
character: Character,
|
||||
input: CharacterInput,
|
||||
dt: number,
|
||||
config: CharacterConfig,
|
||||
): void {
|
||||
const speed = config.speed * runFactor(input, config) * dt
|
||||
const forwardX = Math.sin(character.yaw)
|
||||
const forwardZ = -Math.cos(character.yaw)
|
||||
const rightX = Math.cos(character.yaw)
|
||||
const rightZ = Math.sin(character.yaw)
|
||||
character.position.x +=
|
||||
(forwardX * input.forward + rightX * input.right) * speed
|
||||
character.position.z +=
|
||||
(forwardZ * input.forward + rightZ * input.right) * speed
|
||||
}
|
||||
|
||||
function runFactor(input: CharacterInput, config: CharacterConfig): number {
|
||||
return input.run ? config.runMultiplier : 1
|
||||
}
|
||||
}
|
||||
116
engine/world/Collider.ts
Normal file
116
engine/world/Collider.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
|
||||
/** A 2.5D box: horizontal footprint plus top height. */
|
||||
export type BoxCollider = {
|
||||
shape: "box"
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
top: number
|
||||
standable: boolean
|
||||
}
|
||||
|
||||
/** A 2.5D circle: horizontal footprint plus top height. */
|
||||
export type CircleCollider = {
|
||||
shape: "circle"
|
||||
x: number
|
||||
z: number
|
||||
radius: number
|
||||
top: number
|
||||
standable: boolean
|
||||
}
|
||||
|
||||
/** Finite engine collision capabilities, not game-content identity. */
|
||||
export type Collider = BoxCollider | CircleCollider
|
||||
|
||||
export namespace Collider {
|
||||
export function centerX(collider: Collider): number {
|
||||
return collider.shape === "circle"
|
||||
? collider.x
|
||||
: (collider.minX + collider.maxX) * 0.5
|
||||
}
|
||||
|
||||
export function centerZ(collider: Collider): number {
|
||||
return collider.shape === "circle"
|
||||
? collider.z
|
||||
: (collider.minZ + collider.maxZ) * 0.5
|
||||
}
|
||||
|
||||
export function contains(collider: Collider, x: number, z: number): boolean {
|
||||
if (collider.shape === "circle") {
|
||||
const dx = x - collider.x
|
||||
const dz = z - collider.z
|
||||
return dx * dx + dz * dz <= collider.radius * collider.radius
|
||||
}
|
||||
return (
|
||||
x >= collider.minX &&
|
||||
x <= collider.maxX &&
|
||||
z >= collider.minZ &&
|
||||
z <= collider.maxZ
|
||||
)
|
||||
}
|
||||
|
||||
/** Push a horizontal player circle out of one collider. */
|
||||
export function pushOut(
|
||||
collider: Collider,
|
||||
position: Vec3,
|
||||
radius: number,
|
||||
): void {
|
||||
if (collider.shape === "circle") {
|
||||
pushFromCircle(position, radius, collider)
|
||||
return
|
||||
}
|
||||
pushFromBox(position, radius, collider)
|
||||
}
|
||||
|
||||
function pushFromBox(position: Vec3, radius: number, box: BoxCollider): void {
|
||||
const cx = Math.max(box.minX, Math.min(box.maxX, position.x))
|
||||
const cz = Math.max(box.minZ, Math.min(box.maxZ, position.z))
|
||||
const dx = position.x - cx
|
||||
const dz = position.z - cz
|
||||
const distanceSquared = dx * dx + dz * dz
|
||||
if (distanceSquared >= radius * radius) {
|
||||
return
|
||||
}
|
||||
if (distanceSquared > 1e-6) {
|
||||
const distance = Math.sqrt(distanceSquared)
|
||||
const push = (radius - distance) / distance
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
return
|
||||
}
|
||||
const left = position.x - box.minX
|
||||
const right = box.maxX - position.x
|
||||
const near = position.z - box.minZ
|
||||
const far = box.maxZ - position.z
|
||||
const nearest = Math.min(left, right, near, far)
|
||||
if (nearest === left) {
|
||||
position.x = box.minX - radius
|
||||
} else if (nearest === right) {
|
||||
position.x = box.maxX + radius
|
||||
} else if (nearest === near) {
|
||||
position.z = box.minZ - radius
|
||||
} else {
|
||||
position.z = box.maxZ + radius
|
||||
}
|
||||
}
|
||||
|
||||
function pushFromCircle(
|
||||
position: Vec3,
|
||||
radius: number,
|
||||
circle: CircleCollider,
|
||||
): void {
|
||||
const dx = position.x - circle.x
|
||||
const dz = position.z - circle.z
|
||||
const reach = radius + circle.radius
|
||||
const distanceSquared = dx * dx + dz * dz
|
||||
if (distanceSquared >= reach * reach || distanceSquared < 1e-6) {
|
||||
return
|
||||
}
|
||||
const distance = Math.sqrt(distanceSquared)
|
||||
const push = (reach - distance) / distance
|
||||
position.x += dx * push
|
||||
position.z += dz * push
|
||||
}
|
||||
}
|
||||
62
engine/world/CollisionWorld.ts
Normal file
62
engine/world/CollisionWorld.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { Vec3 } from "../math/Vec3"
|
||||
import { Collider, type Collider as ColliderShape } from "./Collider"
|
||||
import type { Terrain } from "./Terrain"
|
||||
|
||||
export type CollisionWorld = {
|
||||
terrain: Terrain
|
||||
staticColliders: ColliderShape[]
|
||||
dynamicColliders: ColliderShape[]
|
||||
}
|
||||
|
||||
export namespace CollisionWorld {
|
||||
export function create(
|
||||
terrain: Terrain,
|
||||
staticColliders: ColliderShape[],
|
||||
): CollisionWorld {
|
||||
return { terrain, staticColliders, dynamicColliders: [] }
|
||||
}
|
||||
|
||||
export function pushOut(
|
||||
world: CollisionWorld,
|
||||
position: Vec3,
|
||||
radius: number,
|
||||
): void {
|
||||
pushFrom(world.staticColliders, position, radius)
|
||||
pushFrom(world.dynamicColliders, position, radius)
|
||||
}
|
||||
|
||||
export function groundHeight(world: CollisionWorld, position: Vec3): number {
|
||||
let ground = world.terrain.heightAt(position.x, position.z)
|
||||
ground = standingHeight(world.staticColliders, position, ground)
|
||||
return standingHeight(world.dynamicColliders, position, ground)
|
||||
}
|
||||
|
||||
function pushFrom(
|
||||
colliders: ColliderShape[],
|
||||
position: Vec3,
|
||||
radius: number,
|
||||
): void {
|
||||
for (const collider of colliders) {
|
||||
if (position.y < collider.top - 0.01) {
|
||||
Collider.pushOut(collider, position, radius)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function standingHeight(
|
||||
colliders: ColliderShape[],
|
||||
position: Vec3,
|
||||
initial: number,
|
||||
): number {
|
||||
let ground = initial
|
||||
for (const collider of colliders) {
|
||||
if (
|
||||
collider.standable &&
|
||||
Collider.contains(collider, position.x, position.z)
|
||||
) {
|
||||
ground = Math.max(ground, collider.top)
|
||||
}
|
||||
}
|
||||
return ground
|
||||
}
|
||||
}
|
||||
123
engine/world/Level.ts
Normal file
123
engine/world/Level.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
Actor,
|
||||
type Actor as RuntimeActor,
|
||||
} from "../scene/Actor"
|
||||
import { Collider, type Collider as CollisionShape } from "./Collider"
|
||||
import {
|
||||
CollisionWorld,
|
||||
type CollisionWorld as Collision,
|
||||
} from "./CollisionWorld"
|
||||
import type { Terrain } from "./Terrain"
|
||||
import {
|
||||
RenderScene,
|
||||
type Billboard,
|
||||
type RenderInstance,
|
||||
type RenderPrototype,
|
||||
type RenderScene as Scene,
|
||||
} from "../render/RenderScene"
|
||||
import type { Chunk } from "../render/Chunk"
|
||||
import type { DrawGroup } from "../render/Material"
|
||||
import type { SkyConfig } from "../render/Sky"
|
||||
import type { Mat4 } from "../math/Mat4"
|
||||
import type { Vec3 } from "../math/Vec3"
|
||||
|
||||
export type LevelDefinition<World> = {
|
||||
terrain: Terrain
|
||||
actorWorld: World
|
||||
actors: readonly RuntimeActor<World>[]
|
||||
staticColliders: CollisionShape[]
|
||||
staticGroups: DrawGroup[]
|
||||
chunks: Chunk[]
|
||||
billboards: Billboard[]
|
||||
sky: SkyConfig
|
||||
}
|
||||
|
||||
/** Live engine world. Behavior-bearing actors stay here on the main thread; only
|
||||
* `render` is clone-safe and sent to workers. */
|
||||
export type Level<World> = {
|
||||
readonly terrain: Terrain
|
||||
readonly actorWorld: World
|
||||
readonly actors: readonly RuntimeActor<World>[]
|
||||
readonly collision: Collision
|
||||
readonly render: Scene
|
||||
}
|
||||
|
||||
const prototypeIndexes = new WeakMap<object, ReadonlyMap<object, number>>()
|
||||
|
||||
export namespace Level {
|
||||
export function create<World>(definition: LevelDefinition<World>): Level<World> {
|
||||
const actors = Object.freeze([...definition.actors])
|
||||
const prototypes: RenderPrototype[] = []
|
||||
const prototypeIndex = new Map<object, number>()
|
||||
for (const actor of actors) {
|
||||
if (!prototypeIndex.has(actor.definition)) {
|
||||
prototypeIndex.set(actor.definition, prototypes.length)
|
||||
prototypes.push(actor.prototype)
|
||||
}
|
||||
}
|
||||
const level: Level<World> = {
|
||||
terrain: definition.terrain,
|
||||
actorWorld: definition.actorWorld,
|
||||
actors,
|
||||
collision: CollisionWorld.create(
|
||||
definition.terrain,
|
||||
definition.staticColliders,
|
||||
),
|
||||
render: Object.freeze({
|
||||
chunks: Object.freeze([...definition.chunks]),
|
||||
staticGroups: Object.freeze([...definition.staticGroups]),
|
||||
billboards: Object.freeze([...definition.billboards]),
|
||||
prototypes: Object.freeze(prototypes),
|
||||
maxInstances: actors.length,
|
||||
sky: definition.sky,
|
||||
}),
|
||||
}
|
||||
prototypeIndexes.set(level, prototypeIndex)
|
||||
return level
|
||||
}
|
||||
|
||||
export function update<World>(level: Level<World>, dt: number): void {
|
||||
for (const actor of level.actors) {
|
||||
Actor.update(actor, dt, level.actorWorld)
|
||||
}
|
||||
}
|
||||
|
||||
export function visibleInstances<World>(
|
||||
level: Level<World>,
|
||||
viewProjection: Mat4,
|
||||
): RenderInstance[] {
|
||||
const prototypeIndex = prototypeIndexes.get(level)
|
||||
if (prototypeIndex === undefined) {
|
||||
throw new Error("level was not created by Level.create")
|
||||
}
|
||||
const instances: RenderInstance[] = []
|
||||
for (const actor of level.actors) {
|
||||
const prototype = prototypeIndex.get(actor.definition)
|
||||
if (prototype !== undefined) {
|
||||
instances.push({ prototype, ...Actor.transform(actor) })
|
||||
}
|
||||
}
|
||||
return RenderScene.visibleInstances(level.render, instances, viewProjection)
|
||||
}
|
||||
|
||||
export function refreshActorColliders<World>(
|
||||
level: Level<World>,
|
||||
focus: Vec3,
|
||||
range: number,
|
||||
): void {
|
||||
const dynamic = level.collision.dynamicColliders
|
||||
dynamic.length = 0
|
||||
const rangeSquared = range * range
|
||||
for (const actor of level.actors) {
|
||||
const collider = Actor.collider(actor, level.actorWorld)
|
||||
if (collider === null) {
|
||||
continue
|
||||
}
|
||||
const dx = Collider.centerX(collider) - focus.x
|
||||
const dz = Collider.centerZ(collider) - focus.z
|
||||
if (dx * dx + dz * dz <= rangeSquared) {
|
||||
dynamic.push(collider)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
121
engine/world/Terrain.ts
Normal file
121
engine/world/Terrain.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { STRIDE, type Mesh } from "../scene/Mesh"
|
||||
|
||||
/** A bounded ground surface. Implementations may be procedural, sampled, or
|
||||
* loaded; callers only depend on world-space height sampling. */
|
||||
export type Terrain = {
|
||||
minX: number
|
||||
minZ: number
|
||||
maxX: number
|
||||
maxZ: number
|
||||
heightAt: (x: number, z: number) => number
|
||||
}
|
||||
|
||||
/** Parameters for the built-in rolling terrain generator. Values describe the
|
||||
* surface only; level-specific holes and materials belong to level data. */
|
||||
export type RollingTerrainConfig = {
|
||||
inner: number
|
||||
outer: number
|
||||
blend: number
|
||||
amplitude: number
|
||||
frequency: number
|
||||
peakHeight: number
|
||||
peakFrequency: number
|
||||
peakStart: number
|
||||
}
|
||||
|
||||
export namespace Terrain {
|
||||
/** Built-in square world with a flat center, rolling hills, and edge ridges. */
|
||||
export function rolling(config: RollingTerrainConfig): Terrain {
|
||||
return {
|
||||
minX: -config.outer,
|
||||
minZ: -config.outer,
|
||||
maxX: config.outer,
|
||||
maxZ: config.outer,
|
||||
heightAt(x, z) {
|
||||
const r = Math.max(Math.abs(x), Math.abs(z))
|
||||
if (r <= config.inner) {
|
||||
return 0
|
||||
}
|
||||
const rise = smoothstep(config.inner, config.inner + config.blend, r)
|
||||
const hills = config.amplitude * bumps(x, z, config.frequency)
|
||||
const k = Math.min(
|
||||
1,
|
||||
(r - config.inner) / (config.outer - config.inner),
|
||||
)
|
||||
const peaks =
|
||||
config.peakHeight *
|
||||
ridges(x, z, config.peakFrequency) *
|
||||
smoothstep(config.peakStart, 1, k)
|
||||
return rise * (hills + peaks)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function height(terrain: Terrain, x: number, z: number): number {
|
||||
return terrain.heightAt(x, z)
|
||||
}
|
||||
|
||||
/** Append one sampled heightfield patch. `include` is level policy evaluated at
|
||||
* each quad center, allowing arbitrary holes without teaching terrain what
|
||||
* occupies them. */
|
||||
export function patch(
|
||||
terrain: Terrain,
|
||||
mesh: Mesh,
|
||||
x0: number,
|
||||
z0: number,
|
||||
x1: number,
|
||||
z1: number,
|
||||
cols: number,
|
||||
rows: number,
|
||||
uvScale: number,
|
||||
include?: (x: number, z: number) => boolean,
|
||||
): void {
|
||||
const base = mesh.verts.length / STRIDE
|
||||
const dx = (x1 - x0) / cols
|
||||
const dz = (z1 - z0) / rows
|
||||
const rowLength = cols + 1
|
||||
for (let row = 0; row <= rows; row++) {
|
||||
const z = z0 + row * dz
|
||||
for (let col = 0; col <= cols; col++) {
|
||||
const x = x0 + col * dx
|
||||
mesh.verts.push(x, terrain.heightAt(x, z), z, x * uvScale, z * uvScale)
|
||||
}
|
||||
}
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
const cx = x0 + (col + 0.5) * dx
|
||||
const cz = z0 + (row + 0.5) * dz
|
||||
if (include !== undefined && !include(cx, cz)) {
|
||||
continue
|
||||
}
|
||||
const p = base + row * rowLength + col
|
||||
mesh.indices.push(
|
||||
p,
|
||||
p + rowLength + 1,
|
||||
p + 1,
|
||||
p,
|
||||
p + rowLength,
|
||||
p + rowLength + 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bumps(x: number, z: number, frequency: number): number {
|
||||
const a = Math.sin(x * frequency) * Math.cos(z * frequency)
|
||||
const b = Math.sin((x + z) * frequency * 0.5 + 1.7) * 0.5
|
||||
return (a + b + 1.5) / 3
|
||||
}
|
||||
|
||||
function ridges(x: number, z: number, frequency: number): number {
|
||||
const n =
|
||||
Math.sin(x * frequency + 1.3) * Math.cos(z * frequency - 0.7) * 0.7 +
|
||||
Math.sin((x + z) * frequency * 0.6 + 2.5) * 0.3
|
||||
return 1 - Math.abs(n)
|
||||
}
|
||||
|
||||
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