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)

OP OpenVibers committed 24 days ago · 2b0840cd69 · 6 files, +308 −5

* Scraplandia merges its statics for drawing: texture tiling baked into UVs and tints into vertex colours, one material per texture per 32 m square; lamps switch off by day; town's statics went from 165 draw calls to 44 and shadow casters from 439 to 178

* staticBatches.test: the scene's meshes are AbstractMeshes (solution typecheck)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3c33b59..714e9eb 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 world
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
ddiff --git a/apps/client/src/render/environment.ts b/apps/client/src/render/environment.ts
iindex 6778d87..e9a003e 100644
---- a/apps/client/src/render/environment.ts
++++ b/apps/client/src/render/environment.ts
@@@ -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 false
const 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
ddiff --git a/apps/client/src/render/sceneSetup.ts b/apps/client/src/render/sceneSetup.ts
iindex 447e293..0de9375 100644
---- a/apps/client/src/render/sceneSetup.ts
++++ b/apps/client/src/render/sceneSetup.ts
@@@ -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 ?? '')) continue
renderStaticBody(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 = 60
export 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}`)) : null
for (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)
ddiff --git a/apps/client/src/render/staticBatches.test.ts b/apps/client/src/render/staticBatches.test.ts
nnew file mode 100644
iindex 0000000..0987b68
---- /dev/null
++++ b/apps/client/src/render/staticBatches.test.ts
@@@ -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])
++ })
++})
ddiff --git a/apps/client/src/render/staticBatches.ts b/apps/client/src/render/staticBatches.ts
nnew file mode 100644
iindex 0000000..8f9f4a4
---- /dev/null
++++ b/apps/client/src/render/staticBatches.ts
@@@ -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
++}
ddiff --git a/test/apps__client__src__render__staticBatches.test.js b/test/apps__client__src__render__staticBatches.test.js
nnew file mode 100644
iindex 0000000..8d99b72
---- /dev/null
++++ b/test/apps__client__src__render__staticBatches.test.js
@@@ -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.