openvibers/openvibe.games Commits
Scraplandia merges its statics for drawing (one material per texture per 32 m square): in town, statics 165 draw calls to 44, shadow casters 439 to 178, the frame ~400 to ~230 (#121)
Changes
@@@ -8,6 +8,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Fixed++- **Scrap City drew every static on its own.** It is several hundred static meshes (walls, wall plates, buildings,++ roofs, doors, stalls), and each textured one had a material of its own because its texture's scale follows its size.++ That came to 165 draw calls in view and 298 shadow casters, with 525 materials.++ - Statics are now merged once built (`render/staticBatches.ts`). Each static's texture tiling is baked into its UVs++ and its tint into its vertex colours, so everything with one texture shares one material, per 32 m square.++ - Lamps, the fountain, statics the water reflects, painted surfaces, per-face styles and glb models stay as they++ were.++ - A live map save unbatches, rebuilds what changed and batches again.++ - Lamp lights switch off by day, not just dim: at intensity 0 a lamp still cost every pixel it was included on. A++ batch is under the lamps' size limit (60 m; the terrain is 210 m).++ - In town: statics went from 165 draw calls to 44 and shadow casters from 439 to 178. The frame came to about 230++ draw calls (720 this evening), and a test browser ran at 38–44 fps instead of 8–13.- **Far avatars floated, twisted.** The merged copy that stands in for an avatar past 18 m baked each part's worldposition, but a Babylon clone keeps its source's parent, so the joint chain applied a second time: far NPCs hung0.4 m up, out of shape. Each copy now drops its parent before baking, and a test checks that the copy stands on its@@@ -302,7 +302,10 @@ export class Environment {// Town lights: any light named 'lamp:*' fades up after dark.const lampGlow = Math.min(1, Math.max(0, 0.15 - elevation) * 6)for (const light of this.scene.lights) {-- if (light.name.startsWith('lamp:')) light.intensity = lampGlow * 0.9++ if (!light.name.startsWith('lamp:')) continue++ light.intensity = lampGlow * 0.9++ // Off by day, not just dark: a lamp at intensity 0 still costs every pixel it is included on a light loop.++ if (light.isEnabled() !== lampGlow > 0) light.setEnabled(lampGlow > 0)}}}@@@ -314,7 +317,7 @@ const SHADOW_REFRESH_FRAMES = 3/** What casts a sun shadow: world geometry, trees and nodes, props and people, near the player. */const CASTER = /^(mapstatic|static|res|tree|prop|npc|player):/function castsShadow(mesh: AbstractMesh, center: Vector3): boolean {-- if (!CASTER.test(mesh.name)) return false++ if (!CASTER.test(mesh.name) || !mesh.isEnabled()) return falseconst sphere = mesh.getBoundingInfo().boundingSphere// Small things (pebbles, branches) add nothing a player notices; past the box's edge nothing lands inside it.if (sphere.radiusWorld < 0.5) return false@@@ -40,6 +40,7 @@ import {type QualitySettings,} from './quality.js'import { Water } from './water.js'++import { batchMapStatics, unbatchMapStatics } from './staticBatches.js'import {applyPatchTexture,applyStaticStyle,@@@ -342,6 +343,7 @@ export function buildMapStaticVisuals(if (only && !only.has(s.id ?? '')) continuerenderStaticBody(scene, water, s, `mapstatic:${s.id ?? ''}`)}++ batchMapStatics(scene)fitLampLights(scene)}@@@ -350,8 +352,8 @@ export function buildMapStaticVisuals(* material has a handful of light slots: with every lamp on every mesh, the first few lamps took the slots* everywhere and the rest lit nothing.*/--/** Meshes bigger than this (the terrain) are never lit by a lamp. */--const LAMP_MESH_MAX_RADIUS = 40++/** Meshes bigger than this (the terrain, 210 m) are never lit by a lamp; a batch of statics (32 m cells) is smaller. */++const LAMP_MESH_MAX_RADIUS = 60export function fitLampLights(scene: Scene): void {const world = [...scene.meshes].filter((m) => /^(static|mapstatic|patch):|^terrain/.test(m.name))@@@ -376,6 +378,8 @@ export function fitLampLights(scene: Scene): void {* when omitted), leaving base content alone.*/export function rebuildMapStaticVisuals(scene: Scene, ids?: readonly string[]): void {++ // Back to one mesh per static first; buildMapStaticVisuals merges them again.++ unbatchMapStatics(scene)const wanted = ids ? new Set(ids.map((id) => `mapstatic:${id}`)) : nullfor (const m of [...scene.meshes]) {if (!m.name.startsWith('mapstatic:')) continue@@@ -405,7 +409,11 @@ function renderStaticBody(mesh.rotationQuaternion = s.rot? Quaternion.FromEulerAngles(s.rot[0], s.rot[1], s.rot[2]): Quaternion.FromEulerAngles(0, s.yaw, 0)-- if (water && nearWater(s.pos[0], s.pos[2])) water.addToRenderList(mesh)++ if (water && nearWater(s.pos[0], s.pos[2])) {++ water.addToRenderList(mesh)++ // Reflected by the water, which holds this mesh: never merged into a batch (staticBatches.ts).++ mesh.metadata = { ...(mesh.metadata as object | null), water: true }++ }if (s.decor === 'building') decorateBuilding(scene, mesh, s)else if (s.decor === 'fountain' && water) buildFountain(scene, mesh, s, water)else if (s.decor === 'lamp') decorateLamp(scene, mesh, s, decorSeed ?? 0)nnew file mode 100644@@@ -0,0 +1,95 @@++import { afterEach, beforeEach, describe, expect, it } from 'vitest'++import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'++import { Scene } from '@babylonjs/core/scene.js'++import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'++import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'++import { PointLight } from '@babylonjs/core/Lights/pointLight.js'++import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'++import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'++import { bakeUvs, batchMapStatics, unbatchMapStatics } from './staticBatches.js'++++let engine: NullEngine++let scene: Scene++let mat: StandardMaterial++beforeEach(() => {++ engine = new NullEngine()++ scene = new Scene(engine)++ mat = new StandardMaterial('m', scene)++})++afterEach(() => {++ scene.dispose()++ engine.dispose()++})++++/** A static like renderStaticBody makes: positioned, turned, its world matrix frozen. */++function staticAt(id: string, x: number, y: number, z: number, yaw = 0): Mesh {++ const m = CreateBox(`mapstatic:${id}`, { size: 2 }, scene)++ m.material = mat++ m.position.set(x, y, z)++ m.rotationQuaternion = Quaternion.FromEulerAngles(0, yaw, 0)++ return m++}++const freeze = (m: Mesh) => [m, ...m.getChildMeshes()].forEach((c) => c.freezeWorldMatrix())++const batches = () => scene.meshes.filter((m) => m.name.startsWith('mapstatic:batch:'))++const contains = (m: AbstractMesh, p: Vector3) => {++ m.refreshBoundingInfo(false, false)++ const { minimumWorld: lo, maximumWorld: hi } = m.getBoundingInfo().boundingBox++ return (++ p.x >= lo.x - 0.01 &&++ p.y >= lo.y - 0.01 &&++ p.z >= lo.z - 0.01 &&++ p.x <= hi.x + 0.01 &&++ p.y <= hi.y + 0.01 &&++ p.z <= hi.z + 0.01++ )++}++++describe('batchMapStatics', () => {++ it('merges statics where they stand, children of a building turned 180° included', () => {++ const house = staticAt('house', 10, 2, 5, Math.PI)++ const roof = CreateBox('mapstatic:house:roof', { size: 1 }, scene)++ roof.material = mat++ roof.parent = house++ roof.position.y = 3++ freeze(house)++ const wall = staticAt('wall', 12, 1, 6)++ freeze(wall)++++ batchMapStatics(scene)++ const [batch, ...rest] = batches()++ expect(rest).toEqual([]) // same material, same 32 m cell: one batch++ expect(batch).toBeDefined()++ expect(contains(batch!, new Vector3(10, 5, 5))).toBe(true) // the roof, on the house++ expect(contains(batch!, new Vector3(0, 7, 0))).toBe(false) // not over the origin++ expect(house.isEnabled()).toBe(false)++ expect(wall.isEnabled()).toBe(false)++++ unbatchMapStatics(scene)++ expect(batches()).toEqual([])++ expect(house.isEnabled()).toBe(true)++ expect(roof.isEnabled()).toBe(true)++ })++++ it('leaves lamps (a light in the tree) and statics the water reflects as they are', () => {++ const lamp = staticAt('lamp', 0, 2, 0)++ const light = new PointLight('lamp:0', new Vector3(0, 1.9, 0), scene)++ light.parent = lamp++ freeze(lamp)++ const pier = staticAt('pier', 4, 0, 4)++ pier.metadata = { water: true }++ freeze(pier)++ batchMapStatics(scene)++ expect(batches()).toEqual([])++ expect(lamp.isEnabled()).toBe(true)++ expect(pier.isEnabled()).toBe(true)++ })++})++++describe('bakeUvs', () => {++ it('applies a texture matrix the way the shader does', () => {++ expect([...bakeUvs([0.25, 0.75], Matrix.Identity())]).toEqual([0.25, 0.75])++ const scale = Matrix.Scaling(2, 3, 1)++ expect([...bakeUvs([1, 1], scale)]).toEqual([2, 3])++ })++})nnew file mode 100644@@@ -0,0 +1,182 @@++import { VertexBuffer } from '@babylonjs/core/Buffers/buffer.js'++import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'++import { Color3 } from '@babylonjs/core/Maths/math.color.js'++import type { Texture } from '@babylonjs/core/Materials/Textures/texture.js'++import type { Matrix } from '@babylonjs/core/Maths/math.vector.js'++import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'++import { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import type { Scene } from '@babylonjs/core/scene.js'++++/**++ * The map's statics, merged for drawing. Scrap City is several hundred static meshes (walls, wall plates, buildings,++ * roofs, doors, stalls), each its own draw call, and the sun's shadow pass drew most of them again: 165 draws in view++ * and 298 shadow casters. Each textured static also had a material of its own, because its texture's scale follows++ * its size.++ *++ * So, after the statics are built, every static whose whole tree is plain (ordinary materials, no lights, no glb++ * model, no paint layers, not reflected by water) is copied with its texture transform baked into its UVs and merged++ * with the others that share its texture and tint in the same CELL-metre square. The sources stay, disabled, under++ * their ids: a live map save unbatches, rebuilds what changed and batches again.++ */++++/** Batches are this many metres square, so a batch outside the view is still culled. */++const CELL = 32++const BATCH_PREFIX = 'mapstatic:batch:'++++interface Batched {++ batches: Mesh[]++ sources: AbstractMesh[]++}++const state = new WeakMap<Scene, Batched>()++const materials = new WeakMap<Scene, Map<string, StandardMaterial>>()++++/** Merge the eligible map statics (meshes named `mapstatic:<id>`, with their children). */++export function batchMapStatics(scene: Scene): void {++ unbatchMapStatics(scene)++ const groups = new Map<string, { key: string; material: StandardMaterial; parts: Mesh[] }>()++ const sources: AbstractMesh[] = []++ for (const root of [...scene.meshes]) {++ if (!root.name.startsWith('mapstatic:') || root.name.startsWith(BATCH_PREFIX) || root.parent)++ continue++ const tree = [root, ...root.getChildMeshes(false)]++ if (++ !tree.every(plain) ||++ hasLight(root) ||++ (root.metadata as { water?: boolean } | null)?.water++ )++ continue++ for (const mesh of tree) {++ if (!(mesh instanceof Mesh) || !mesh.getTotalVertices() || !mesh.isVisible) continue++ const material = mesh.material as StandardMaterial++ const key = materialKey(material)++ const centre = mesh.getBoundingInfo().boundingSphere.centerWorld++ const cell = `${Math.floor(centre.x / CELL)},${Math.floor(centre.z / CELL)}`++ const groupKey = `${cell}|${key}`++ let group = groups.get(groupKey)++ if (!group) {++ group = { key, material: batchMaterial(scene, key, material), parts: [] }++ groups.set(groupKey, group)++ }++ group.parts.push(bakedCopy(mesh, material))++ }++ root.setEnabled(false)++ sources.push(root)++ }++ const batches: Mesh[] = []++ let n = 0++ for (const group of groups.values()) {++ const merged = Mesh.MergeMeshes(group.parts, true, true)++ if (!merged) continue++ merged.name = `${BATCH_PREFIX}${n++}`++ merged.material = group.material++ merged.isPickable = false++ merged.receiveShadows = true++ merged.freezeWorldMatrix()++ batches.push(merged)++ }++ state.set(scene, { batches, sources })++}++++/** Undo batchMapStatics: drop the batches and show the sources again. */++export function unbatchMapStatics(scene: Scene): void {++ const s = state.get(scene)++ if (!s) return++ for (const b of s.batches) b.dispose(false, false)++ for (const m of s.sources) if (!m.isDisposed()) m.setEnabled(true)++ state.delete(scene)++}++++/** A copy of `mesh` in world space, its texture's tiling baked into its UVs, ready to merge. */++function bakedCopy(mesh: Mesh, material: StandardMaterial): Mesh {++ const copy = mesh.clone(`${mesh.name}:baked`, null, true)++ copy.makeGeometryUnique()++ // A clone keeps its source's parent (whatever clone's argument says) and frozen world matrix: either would apply++ // the transform a second time on top of the baked one (a building turned 180° put its roof over the spawn).++ copy.parent = null++ copy.unfreezeWorldMatrix()++ copy.setEnabled(true)++ copy.position.setAll(0)++ copy.rotationQuaternion = null++ copy.rotation.setAll(0)++ copy.scaling.setAll(1)++ copy.bakeTransformIntoVertices(mesh.computeWorldMatrix(true))++ copy.computeWorldMatrix(true)++ const tex = material.diffuseTexture as Texture | null++ if (tex) {++ const uvs = copy.getVerticesData(VertexBuffer.UVKind)++ if (uvs) copy.setVerticesData(VertexBuffer.UVKind, bakeUvs(uvs, tex.getTextureMatrix()))++ }++ // The tint rides on the vertices, so every static with this texture (or none) shares one white material.++ const c = material.diffuseColor++ const colors = new Float32Array(copy.getTotalVertices() * 4)++ for (let i = 0; i < colors.length; i += 4) colors.set([c.r, c.g, c.b, 1], i)++ copy.setVerticesData(VertexBuffer.ColorKind, colors)++ return copy++}++++/**++ * Apply a texture matrix to UVs the way the shader does (`textureMatrix * vec4(uv, 1.0, 0.0)`), so the merged mesh++ * can sample an untransformed texture and look the same.++ */++export function bakeUvs(uvs: ArrayLike<number>, m: Matrix): Float32Array {++ const a = m.m++ const out = new Float32Array(uvs.length)++ for (let i = 0; i < uvs.length; i += 2) {++ const u = uvs[i]!++ const v = uvs[i + 1]!++ out[i] = a[0]! * u + a[4]! * v + a[8]!++ out[i + 1] = a[1]! * u + a[5]! * v + a[9]!++ }++ return out++}++++/** An ordinary material: plain StandardMaterial, at most a diffuse texture (no paint layers, no multi-material). */++function plain(mesh: AbstractMesh): boolean {++ const mat = mesh.material++ if (!mat) return false++ if (mat.getClassName() !== 'StandardMaterial') return false++ const s = mat as StandardMaterial++ return (++ !s.opacityTexture && !s.bumpTexture && !s.emissiveTexture && !s.ambientTexture && s.alpha === 1++ )++}++++function hasLight(root: AbstractMesh): boolean {++ return root.getDescendants(false).some((d) => d.getClassName().endsWith('Light'))++}++++/** Statics batch by texture alone (their tints are vertex colours): one key per texture, one for plain colour. */++function materialKey(m: StandardMaterial): string {++ const tex = m.diffuseTexture as Texture | null++ return tex ? `${tex.url ?? tex.name}|${tex.hasAlpha ? 1 : 0}` : 'plain'++}++++/** One shared white material per key (the vertices carry the tint), with an untransformed copy of the texture. */++function batchMaterial(scene: Scene, key: string, source: StandardMaterial): StandardMaterial {++ const tex = source.diffuseTexture as Texture | null++ let cache = materials.get(scene)++ if (!cache) {++ cache = new Map()++ materials.set(scene, cache)++ }++ let mat = cache.get(key)++ if (!mat) {++ mat = new StandardMaterial(`batch:${cache.size}`, scene)++ mat.maxSimultaneousLights = 8++ mat.diffuseColor = Color3.White()++ mat.specularColor = source.specularColor.clone()++ if (tex) {++ const t = tex.clone()++ t.uScale = 1++ t.vScale = 1++ t.uOffset = 0++ t.vOffset = 0++ t.uAng = 0++ t.vAng = 0++ t.wAng = 0++ mat.diffuseTexture = t++ }++ cache.set(key, mat)++ }++ return mat++}nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/render/staticBatches.test.ts'++// covers: 'apps/client/src/render'++// Generated by node test/run.js --index; do not edit.
@@ -8,6 +8,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -8,6 +8,18 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixed
### Fixed
+- **Scrap City drew every static on its own.** It is several hundred static meshes (walls, wall plates, buildings,
+ roofs, doors, stalls), and each textured one had a material of its own because its texture's scale follows its size.
+ That came to 165 draw calls in view and 298 shadow casters, with 525 materials.
+ - Statics are now merged once built (`render/staticBatches.ts`). Each static's texture tiling is baked into its UVs
+ and its tint into its vertex colours, so everything with one texture shares one material, per 32 m square.
+ - Lamps, the fountain, statics the water reflects, painted surfaces, per-face styles and glb models stay as they
+ were.
+ - A live map save unbatches, rebuilds what changed and batches again.
+ - Lamp lights switch off by day, not just dim: at intensity 0 a lamp still cost every pixel it was included on. A
+ batch is under the lamps' size limit (60 m; the terrain is 210 m).
+ - In town: statics went from 165 draw calls to 44 and shadow casters from 439 to 178. The frame came to about 230
+ draw calls (720 this evening), and a test browser ran at 38–44 fps instead of 8–13.
- **Far avatars floated, twisted.** The merged copy that stands in for an avatar past 18 m baked each part's world
- **Far avatars floated, twisted.** The merged copy that stands in for an avatar past 18 m baked each part's world
position, but a Babylon clone keeps its source's parent, so the joint chain applied a second time: far NPCs hung
position, but a Babylon clone keeps its source's parent, so the joint chain applied a second time: far NPCs hung
0.4 m up, out of shape. Each copy now drops its parent before baking, and a test checks that the copy stands on its
0.4 m up, out of shape. Each copy now drops its parent before baking, and a test checks that the copy stands on its
@@ -302,7 +302,10 @@ export class Environment {
@@ -302,7 +302,10 @@ export class Environment {
// Town lights: any light named 'lamp:*' fades up after dark.
// Town lights: any light named 'lamp:*' fades up after dark.
const lampGlow = Math.min(1, Math.max(0, 0.15 - elevation) * 6)
const lampGlow = Math.min(1, Math.max(0, 0.15 - elevation) * 6)
for (const light of this.scene.lights) {
for (const light of this.scene.lights) {
- if (light.name.startsWith('lamp:')) light.intensity = lampGlow * 0.9
+ if (!light.name.startsWith('lamp:')) continue
+ light.intensity = lampGlow * 0.9
+ // Off by day, not just dark: a lamp at intensity 0 still costs every pixel it is included on a light loop.
+ if (light.isEnabled() !== lampGlow > 0) light.setEnabled(lampGlow > 0)
}
}
}
}
}
}
@@ -314,7 +317,7 @@ const SHADOW_REFRESH_FRAMES = 3
@@ -314,7 +317,7 @@ const SHADOW_REFRESH_FRAMES = 3
/** What casts a sun shadow: world geometry, trees and nodes, props and people, near the player. */
/** What casts a sun shadow: world geometry, trees and nodes, props and people, near the player. */
const CASTER = /^(mapstatic|static|res|tree|prop|npc|player):/
const CASTER = /^(mapstatic|static|res|tree|prop|npc|player):/
function castsShadow(mesh: AbstractMesh, center: Vector3): boolean {
function castsShadow(mesh: AbstractMesh, center: Vector3): boolean {
- if (!CASTER.test(mesh.name)) return false
+ if (!CASTER.test(mesh.name) || !mesh.isEnabled()) return false
const sphere = mesh.getBoundingInfo().boundingSphere
const sphere = mesh.getBoundingInfo().boundingSphere
// Small things (pebbles, branches) add nothing a player notices; past the box's edge nothing lands inside it.
// Small things (pebbles, branches) add nothing a player notices; past the box's edge nothing lands inside it.
if (sphere.radiusWorld < 0.5) return false
if (sphere.radiusWorld < 0.5) return false
@@ -40,6 +40,7 @@ import {
@@ -40,6 +40,7 @@ import {
type QualitySettings,
type QualitySettings,
} from './quality.js'
} from './quality.js'
import { Water } from './water.js'
import { Water } from './water.js'
+import { batchMapStatics, unbatchMapStatics } from './staticBatches.js'
import {
import {
applyPatchTexture,
applyPatchTexture,
applyStaticStyle,
applyStaticStyle,
@@ -342,6 +343,7 @@ export function buildMapStaticVisuals(
@@ -342,6 +343,7 @@ export function buildMapStaticVisuals(
if (only && !only.has(s.id ?? '')) continue
if (only && !only.has(s.id ?? '')) continue
renderStaticBody(scene, water, s, `mapstatic:${s.id ?? ''}`)
renderStaticBody(scene, water, s, `mapstatic:${s.id ?? ''}`)
}
}
+ batchMapStatics(scene)
fitLampLights(scene)
fitLampLights(scene)
}
}
@@ -350,8 +352,8 @@ export function buildMapStaticVisuals(
@@ -350,8 +352,8 @@ export function buildMapStaticVisuals(
* material has a handful of light slots: with every lamp on every mesh, the first few lamps took the slots
* material has a handful of light slots: with every lamp on every mesh, the first few lamps took the slots
* everywhere and the rest lit nothing.
* everywhere and the rest lit nothing.
*/
*/
-/** Meshes bigger than this (the terrain) are never lit by a lamp. */
-const LAMP_MESH_MAX_RADIUS = 40
+/** Meshes bigger than this (the terrain, 210 m) are never lit by a lamp; a batch of statics (32 m cells) is smaller. */
+const LAMP_MESH_MAX_RADIUS = 60
export function fitLampLights(scene: Scene): void {
export function fitLampLights(scene: Scene): void {
const world = [...scene.meshes].filter((m) => /^(static|mapstatic|patch):|^terrain/.test(m.name))
const world = [...scene.meshes].filter((m) => /^(static|mapstatic|patch):|^terrain/.test(m.name))
@@ -376,6 +378,8 @@ export function fitLampLights(scene: Scene): void {
@@ -376,6 +378,8 @@ export function fitLampLights(scene: Scene): void {
* when omitted), leaving base content alone.
* when omitted), leaving base content alone.
*/
*/
export function rebuildMapStaticVisuals(scene: Scene, ids?: readonly string[]): void {
export function rebuildMapStaticVisuals(scene: Scene, ids?: readonly string[]): void {
+ // Back to one mesh per static first; buildMapStaticVisuals merges them again.
+ unbatchMapStatics(scene)
const wanted = ids ? new Set(ids.map((id) => `mapstatic:${id}`)) : null
const wanted = ids ? new Set(ids.map((id) => `mapstatic:${id}`)) : null
for (const m of [...scene.meshes]) {
for (const m of [...scene.meshes]) {
if (!m.name.startsWith('mapstatic:')) continue
if (!m.name.startsWith('mapstatic:')) continue
@@ -405,7 +409,11 @@ function renderStaticBody(
@@ -405,7 +409,11 @@ function renderStaticBody(
mesh.rotationQuaternion = s.rot
mesh.rotationQuaternion = s.rot
? Quaternion.FromEulerAngles(s.rot[0], s.rot[1], s.rot[2])
? Quaternion.FromEulerAngles(s.rot[0], s.rot[1], s.rot[2])
: Quaternion.FromEulerAngles(0, s.yaw, 0)
: Quaternion.FromEulerAngles(0, s.yaw, 0)
- if (water && nearWater(s.pos[0], s.pos[2])) water.addToRenderList(mesh)
+ if (water && nearWater(s.pos[0], s.pos[2])) {
+ water.addToRenderList(mesh)
+ // Reflected by the water, which holds this mesh: never merged into a batch (staticBatches.ts).
+ mesh.metadata = { ...(mesh.metadata as object | null), water: true }
+ }
if (s.decor === 'building') decorateBuilding(scene, mesh, s)
if (s.decor === 'building') decorateBuilding(scene, mesh, s)
else if (s.decor === 'fountain' && water) buildFountain(scene, mesh, s, water)
else if (s.decor === 'fountain' && water) buildFountain(scene, mesh, s, water)
else if (s.decor === 'lamp') decorateLamp(scene, mesh, s, decorSeed ?? 0)
else if (s.decor === 'lamp') decorateLamp(scene, mesh, s, decorSeed ?? 0)
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
+import { Scene } from '@babylonjs/core/scene.js'
+import { Matrix, Quaternion, Vector3 } from '@babylonjs/core/Maths/math.vector.js'
+import { CreateBox } from '@babylonjs/core/Meshes/Builders/boxBuilder.js'
+import { PointLight } from '@babylonjs/core/Lights/pointLight.js'
+import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
+import type { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
+import { bakeUvs, batchMapStatics, unbatchMapStatics } from './staticBatches.js'
+
+let engine: NullEngine
+let scene: Scene
+let mat: StandardMaterial
+beforeEach(() => {
+ engine = new NullEngine()
+ scene = new Scene(engine)
+ mat = new StandardMaterial('m', scene)
+})
+afterEach(() => {
+ scene.dispose()
+ engine.dispose()
+})
+
+/** A static like renderStaticBody makes: positioned, turned, its world matrix frozen. */
+function staticAt(id: string, x: number, y: number, z: number, yaw = 0): Mesh {
+ const m = CreateBox(`mapstatic:${id}`, { size: 2 }, scene)
+ m.material = mat
+ m.position.set(x, y, z)
+ m.rotationQuaternion = Quaternion.FromEulerAngles(0, yaw, 0)
+ return m
+}
+const freeze = (m: Mesh) => [m, ...m.getChildMeshes()].forEach((c) => c.freezeWorldMatrix())
+const batches = () => scene.meshes.filter((m) => m.name.startsWith('mapstatic:batch:'))
+const contains = (m: AbstractMesh, p: Vector3) => {
+ m.refreshBoundingInfo(false, false)
+ const { minimumWorld: lo, maximumWorld: hi } = m.getBoundingInfo().boundingBox
+ return (
+ p.x >= lo.x - 0.01 &&
+ p.y >= lo.y - 0.01 &&
+ p.z >= lo.z - 0.01 &&
+ p.x <= hi.x + 0.01 &&
+ p.y <= hi.y + 0.01 &&
+ p.z <= hi.z + 0.01
+ )
+}
+
+describe('batchMapStatics', () => {
+ it('merges statics where they stand, children of a building turned 180° included', () => {
+ const house = staticAt('house', 10, 2, 5, Math.PI)
+ const roof = CreateBox('mapstatic:house:roof', { size: 1 }, scene)
+ roof.material = mat
+ roof.parent = house
+ roof.position.y = 3
+ freeze(house)
+ const wall = staticAt('wall', 12, 1, 6)
+ freeze(wall)
+
+ batchMapStatics(scene)
+ const [batch, ...rest] = batches()
+ expect(rest).toEqual([]) // same material, same 32 m cell: one batch
+ expect(batch).toBeDefined()
+ expect(contains(batch!, new Vector3(10, 5, 5))).toBe(true) // the roof, on the house
+ expect(contains(batch!, new Vector3(0, 7, 0))).toBe(false) // not over the origin
+ expect(house.isEnabled()).toBe(false)
+ expect(wall.isEnabled()).toBe(false)
+
+ unbatchMapStatics(scene)
+ expect(batches()).toEqual([])
+ expect(house.isEnabled()).toBe(true)
+ expect(roof.isEnabled()).toBe(true)
+ })
+
+ it('leaves lamps (a light in the tree) and statics the water reflects as they are', () => {
+ const lamp = staticAt('lamp', 0, 2, 0)
+ const light = new PointLight('lamp:0', new Vector3(0, 1.9, 0), scene)
+ light.parent = lamp
+ freeze(lamp)
+ const pier = staticAt('pier', 4, 0, 4)
+ pier.metadata = { water: true }
+ freeze(pier)
+ batchMapStatics(scene)
+ expect(batches()).toEqual([])
+ expect(lamp.isEnabled()).toBe(true)
+ expect(pier.isEnabled()).toBe(true)
+ })
+})
+
+describe('bakeUvs', () => {
+ it('applies a texture matrix the way the shader does', () => {
+ expect([...bakeUvs([0.25, 0.75], Matrix.Identity())]).toEqual([0.25, 0.75])
+ const scale = Matrix.Scaling(2, 3, 1)
+ expect([...bakeUvs([1, 1], scale)]).toEqual([2, 3])
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,182 @@
@@ -0,0 +1,182 @@
+import { VertexBuffer } from '@babylonjs/core/Buffers/buffer.js'
+import { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
+import { Color3 } from '@babylonjs/core/Maths/math.color.js'
+import type { Texture } from '@babylonjs/core/Materials/Textures/texture.js'
+import type { Matrix } from '@babylonjs/core/Maths/math.vector.js'
+import type { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh.js'
+import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import type { Scene } from '@babylonjs/core/scene.js'
+
+/**
+ * The map's statics, merged for drawing. Scrap City is several hundred static meshes (walls, wall plates, buildings,
+ * roofs, doors, stalls), each its own draw call, and the sun's shadow pass drew most of them again: 165 draws in view
+ * and 298 shadow casters. Each textured static also had a material of its own, because its texture's scale follows
+ * its size.
+ *
+ * So, after the statics are built, every static whose whole tree is plain (ordinary materials, no lights, no glb
+ * model, no paint layers, not reflected by water) is copied with its texture transform baked into its UVs and merged
+ * with the others that share its texture and tint in the same CELL-metre square. The sources stay, disabled, under
+ * their ids: a live map save unbatches, rebuilds what changed and batches again.
+ */
+
+/** Batches are this many metres square, so a batch outside the view is still culled. */
+const CELL = 32
+const BATCH_PREFIX = 'mapstatic:batch:'
+
+interface Batched {
+ batches: Mesh[]
+ sources: AbstractMesh[]
+}
+const state = new WeakMap<Scene, Batched>()
+const materials = new WeakMap<Scene, Map<string, StandardMaterial>>()
+
+/** Merge the eligible map statics (meshes named `mapstatic:<id>`, with their children). */
+export function batchMapStatics(scene: Scene): void {
+ unbatchMapStatics(scene)
+ const groups = new Map<string, { key: string; material: StandardMaterial; parts: Mesh[] }>()
+ const sources: AbstractMesh[] = []
+ for (const root of [...scene.meshes]) {
+ if (!root.name.startsWith('mapstatic:') || root.name.startsWith(BATCH_PREFIX) || root.parent)
+ continue
+ const tree = [root, ...root.getChildMeshes(false)]
+ if (
+ !tree.every(plain) ||
+ hasLight(root) ||
+ (root.metadata as { water?: boolean } | null)?.water
+ )
+ continue
+ for (const mesh of tree) {
+ if (!(mesh instanceof Mesh) || !mesh.getTotalVertices() || !mesh.isVisible) continue
+ const material = mesh.material as StandardMaterial
+ const key = materialKey(material)
+ const centre = mesh.getBoundingInfo().boundingSphere.centerWorld
+ const cell = `${Math.floor(centre.x / CELL)},${Math.floor(centre.z / CELL)}`
+ const groupKey = `${cell}|${key}`
+ let group = groups.get(groupKey)
+ if (!group) {
+ group = { key, material: batchMaterial(scene, key, material), parts: [] }
+ groups.set(groupKey, group)
+ }
+ group.parts.push(bakedCopy(mesh, material))
+ }
+ root.setEnabled(false)
+ sources.push(root)
+ }
+ const batches: Mesh[] = []
+ let n = 0
+ for (const group of groups.values()) {
+ const merged = Mesh.MergeMeshes(group.parts, true, true)
+ if (!merged) continue
+ merged.name = `${BATCH_PREFIX}${n++}`
+ merged.material = group.material
+ merged.isPickable = false
+ merged.receiveShadows = true
+ merged.freezeWorldMatrix()
+ batches.push(merged)
+ }
+ state.set(scene, { batches, sources })
+}
+
+/** Undo batchMapStatics: drop the batches and show the sources again. */
+export function unbatchMapStatics(scene: Scene): void {
+ const s = state.get(scene)
+ if (!s) return
+ for (const b of s.batches) b.dispose(false, false)
+ for (const m of s.sources) if (!m.isDisposed()) m.setEnabled(true)
+ state.delete(scene)
+}
+
+/** A copy of `mesh` in world space, its texture's tiling baked into its UVs, ready to merge. */
+function bakedCopy(mesh: Mesh, material: StandardMaterial): Mesh {
+ const copy = mesh.clone(`${mesh.name}:baked`, null, true)
+ copy.makeGeometryUnique()
+ // A clone keeps its source's parent (whatever clone's argument says) and frozen world matrix: either would apply
+ // the transform a second time on top of the baked one (a building turned 180° put its roof over the spawn).
+ copy.parent = null
+ copy.unfreezeWorldMatrix()
+ copy.setEnabled(true)
+ copy.position.setAll(0)
+ copy.rotationQuaternion = null
+ copy.rotation.setAll(0)
+ copy.scaling.setAll(1)
+ copy.bakeTransformIntoVertices(mesh.computeWorldMatrix(true))
+ copy.computeWorldMatrix(true)
+ const tex = material.diffuseTexture as Texture | null
+ if (tex) {
+ const uvs = copy.getVerticesData(VertexBuffer.UVKind)
+ if (uvs) copy.setVerticesData(VertexBuffer.UVKind, bakeUvs(uvs, tex.getTextureMatrix()))
+ }
+ // The tint rides on the vertices, so every static with this texture (or none) shares one white material.
+ const c = material.diffuseColor
+ const colors = new Float32Array(copy.getTotalVertices() * 4)
+ for (let i = 0; i < colors.length; i += 4) colors.set([c.r, c.g, c.b, 1], i)
+ copy.setVerticesData(VertexBuffer.ColorKind, colors)
+ return copy
+}
+
+/**
+ * Apply a texture matrix to UVs the way the shader does (`textureMatrix * vec4(uv, 1.0, 0.0)`), so the merged mesh
+ * can sample an untransformed texture and look the same.
+ */
+export function bakeUvs(uvs: ArrayLike<number>, m: Matrix): Float32Array {
+ const a = m.m
+ const out = new Float32Array(uvs.length)
+ for (let i = 0; i < uvs.length; i += 2) {
+ const u = uvs[i]!
+ const v = uvs[i + 1]!
+ out[i] = a[0]! * u + a[4]! * v + a[8]!
+ out[i + 1] = a[1]! * u + a[5]! * v + a[9]!
+ }
+ return out
+}
+
+/** An ordinary material: plain StandardMaterial, at most a diffuse texture (no paint layers, no multi-material). */
+function plain(mesh: AbstractMesh): boolean {
+ const mat = mesh.material
+ if (!mat) return false
+ if (mat.getClassName() !== 'StandardMaterial') return false
+ const s = mat as StandardMaterial
+ return (
+ !s.opacityTexture && !s.bumpTexture && !s.emissiveTexture && !s.ambientTexture && s.alpha === 1
+ )
+}
+
+function hasLight(root: AbstractMesh): boolean {
+ return root.getDescendants(false).some((d) => d.getClassName().endsWith('Light'))
+}
+
+/** Statics batch by texture alone (their tints are vertex colours): one key per texture, one for plain colour. */
+function materialKey(m: StandardMaterial): string {
+ const tex = m.diffuseTexture as Texture | null
+ return tex ? `${tex.url ?? tex.name}|${tex.hasAlpha ? 1 : 0}` : 'plain'
+}
+
+/** One shared white material per key (the vertices carry the tint), with an untransformed copy of the texture. */
+function batchMaterial(scene: Scene, key: string, source: StandardMaterial): StandardMaterial {
+ const tex = source.diffuseTexture as Texture | null
+ let cache = materials.get(scene)
+ if (!cache) {
+ cache = new Map()
+ materials.set(scene, cache)
+ }
+ let mat = cache.get(key)
+ if (!mat) {
+ mat = new StandardMaterial(`batch:${cache.size}`, scene)
+ mat.maxSimultaneousLights = 8
+ mat.diffuseColor = Color3.White()
+ mat.specularColor = source.specularColor.clone()
+ if (tex) {
+ const t = tex.clone()
+ t.uScale = 1
+ t.vScale = 1
+ t.uOffset = 0
+ t.vOffset = 0
+ t.uAng = 0
+ t.vAng = 0
+ t.wAng = 0
+ mat.diffuseTexture = t
+ }
+ cache.set(key, mat)
+ }
+ return mat
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/render/staticBatches.test.ts'
+// covers: 'apps/client/src/render'
+// Generated by node test/run.js --index; do not edit.