openvibers/openvibe.games Commits
Scraplandia: far avatars (NPCs and remote players past 18 m) draw as one merged copy of themselves and skip animation, in place of ~14 meshes each; town's NPCs went from 150 draw calls to 4 (#119)
Changes
@@@ -8,6 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).### Fixed++- **NPCs cost 150 draw calls in town.** Each avatar is about 14 meshes, so eleven townsfolk and wardens in view drew++ 150 times a frame. Past 18 m an avatar, NPC or remote player, now draws as one merged copy of itself in its rest++ pose and skips the animation work. It switches back to the full rig inside 16 m. In town, NPC draws went from 150++ to 4.- **Shadows halved the frame rate in town.** The shadow pass drew every caster in Scrap City again each frame (about390 more draw calls, ~720 a frame), and a test browser dropped from ~50 to ~10 fps. The shadow map is now redrawnevery third frame, and at once whenever the shadow box moves, so no shadow jumps. That gives ~490 draw calls and@@@ -1,13 +1,17 @@import '@babylonjs/core/Rendering/outlineRenderer.js'import { Color3 } from '@babylonjs/core/Maths/math.color.js'import type { Scene } from '@babylonjs/core/scene.js'--import type { Vector3 } from '@babylonjs/core/Maths/math.vector.js'++import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'++import { VertexBuffer } from '@babylonjs/core/Buffers/buffer.js'++import { Mesh } from '@babylonjs/core/Meshes/mesh.js'++import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'import type { ContentRegistry } from '@openvibe/content'import type { Appearance } from '@openvibe/protocol'import { defaultAppearance } from '@openvibe/protocol'import { AvatarAnimator } from './animator.js'import { buildAvatarRig, type AvatarRig } from './rig.js'import { createHeldItemNode, type HeldItemNode } from '../heldItem.js'++import { materialFor } from '../sceneSetup.js'/*** A complete animated player character: parametric rig + procedural@@@ -35,6 +39,14 @@ export interface AvatarUpdate {const HURT_OVERLAY = new Color3(0.9, 0.12, 0.08)++/**++ * Past LOD_FAR metres from the camera an avatar draws as one merged copy of itself in its rest pose (one draw call,++ * not ~14, and no animation work); it comes back to the full rig inside LOD_NEAR. Eleven townsfolk and wardens in++ * view were 150 draw calls.++ */++const LOD_FAR = 18++const LOD_NEAR = 16++export class Avatar {private rig: AvatarRigprivate animator: AvatarAnimator@@@ -48,9 +60,61 @@ export class Avatar {private readonly content: ContentRegistry,private appearance: Appearance,private readonly name: string,++ /** Remote players and NPCs: draw far avatars as one merged mesh (see LOD_FAR). */++ private readonly withLod = false,) {this.rig = buildAvatarRig(scene, appearance, name)this.animator = new AvatarAnimator(this.rig.joints)++ if (withLod) this.lod = this.buildLod()++ }++++ private lod: Mesh | null = null++ private lodActive = false++++ /** The rig's meshes in their rest pose, each tinted its material's colour, merged under one white material. */++ private buildLod(): Mesh | null {++ const root = this.rig.joints.root++ root.computeWorldMatrix(true)++ const toRoot = root.getWorldMatrix().clone().invert()++ const parts: Mesh[] = []++ for (const part of root.getChildMeshes(false)) {++ const material = part.material as StandardMaterial | null++ if (!(part instanceof Mesh) || !part.getTotalVertices() || !material?.diffuseColor) continue++ const copy = part.clone(`${this.name}:lodpart`, null, true)++ copy.makeGeometryUnique()++ copy.position.setAll(0)++ copy.rotationQuaternion = null++ copy.rotation.setAll(0)++ copy.scaling.setAll(1)++ copy.bakeTransformIntoVertices(part.computeWorldMatrix(true).multiply(toRoot))++ 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)++ parts.push(copy)++ }++ const merged = parts.length ? Mesh.MergeMeshes(parts, true, true) : null++ if (!merged) return null++ merged.name = `${this.name}:lod`++ merged.material = materialFor(this.scene, '#ffffff')++ merged.isPickable = false++ merged.setEnabled(false)++ return merged++ }++++ /** Full rig near the camera, the merged copy far from it (with a little hysteresis). */++ private useLod(x: number, y: number, z: number): boolean {++ if (!this.lod) return false++ const cam = this.scene.activeCamera++ if (!cam) return this.lodActive++ const d = Vector3.Distance(cam.globalPosition, new Vector3(x, y, z))++ const far = this.lodActive ? d > LOD_NEAR : d > LOD_FAR++ if (far !== this.lodActive) {++ this.lodActive = far++ this.rig.joints.root.setEnabled(!far)++ this.lod.setEnabled(far)++ }++ return far}static appearanceOrDefault(a: Appearance | undefined): Appearance {@@@ -76,6 +140,9 @@ export class Avatar {this.animator = new AvatarAnimator(this.rig.joints)this.rig.setHeadVisible(this.headVisible)this.rig.setArmsVisible(this.armsVisible)++ this.lod?.dispose()++ this.lod = this.withLod ? this.buildLod() : null++ this.lodActive = false}setHeadVisible(visible: boolean): void {@@@ -121,6 +188,11 @@ export class Avatar {const j = this.rig.jointsj.root.position.set(u.x, u.y, u.z)j.root.rotation.y = u.yaw++ if (this.useLod(u.x, u.y, u.z)) {++ this.lod!.position.set(u.x, u.y, u.z)++ this.lod!.rotation.y = u.yaw++ return++ }if (u.itemDef !== this.toolItemDef) {this.toolItemDef = u.itemDef@@@ -156,5 +228,6 @@ export class Avatar {dispose(): void {this.toolProp?.dispose()this.rig.dispose()++ this.lod?.dispose()}}nnew file mode 100644@@@ -0,0 +1,71 @@++import { afterEach, beforeEach, describe, expect, it } from 'vitest'++import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'++import { Scene } from '@babylonjs/core/scene.js'++import { FreeCamera } from '@babylonjs/core/Cameras/freeCamera.js'++import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'++import { VertexBuffer } from '@babylonjs/core/Buffers/buffer.js'++import { createContent } from '@openvibe/content'++import { defaultAppearance } from '@openvibe/protocol'++import { Avatar, type AvatarUpdate } from './avatar.js'++++let engine: NullEngine++let scene: Scene++let camera: FreeCamera++beforeEach(() => {++ engine = new NullEngine()++ scene = new Scene(engine)++ camera = new FreeCamera('cam', new Vector3(0, 1.6, 0), scene)++ scene.activeCamera = camera++})++afterEach(() => {++ scene.dispose()++ engine.dispose()++})++++const at = (z: number): AvatarUpdate => ({++ dt: 0.016,++ time: 1,++ x: 0,++ y: 0,++ z,++ yaw: 0,++ pitch: 0,++ speed: 2,++ grounded: true,++})++const lodOf = () => scene.getMeshByName('npc:a:lod')++const rigShown = () =>++ scene.meshes.some((m) => m.name.startsWith('npc:a:') && m.name !== 'npc:a:lod' && m.isEnabled())++++describe('avatar level of detail', () => {++ it('draws a far avatar as one merged, vertex-coloured mesh and the full rig up close', () => {++ const avatar = new Avatar(scene, createContent(), defaultAppearance(), 'npc:a', true)++ const lod = lodOf()!++ expect(lod).not.toBeNull()++ expect(lod.isVerticesDataPresent(VertexBuffer.ColorKind)).toBe(true)++++ avatar.update(at(5))++ expect(lod.isEnabled()).toBe(false)++ expect(rigShown()).toBe(true)++++ avatar.update(at(30))++ expect(lod.isEnabled()).toBe(true)++ expect(rigShown()).toBe(false)++ expect(lod.position.z).toBe(30)++++ // Hysteresis: between the two distances it stays as it is.++ avatar.update(at(17))++ expect(lod.isEnabled()).toBe(true)++ avatar.update(at(10))++ expect(lod.isEnabled()).toBe(false)++ expect(rigShown()).toBe(true)++++ avatar.dispose()++ expect(lodOf()).toBeNull()++ })++++ it('builds nothing extra unless asked (the local body and the preview)', () => {++ new Avatar(scene, createContent(), defaultAppearance(), 'npc:a')++ expect(lodOf()).toBeNull()++ })++})@@@ -107,6 +107,7 @@ export class EntityView {this.content,Avatar.appearanceOrDefault(e.appearance),`player:${e.id}`,++ true,)// Collision mirror so local prediction blocks on other players the// same way the server does (players collide with players).@@@ -139,7 +140,7 @@ export class EntityView {const radius = arch?.radius ?? 0.35const look = npcAppearance(arch?.id ?? '', e.id)if (look) {-- avatar = new Avatar(this.scene, this.content, look, `npc:${e.id}`)++ avatar = new Avatar(this.scene, this.content, look, `npc:${e.id}`, true)feetOffset = height / 2} else {mesh = buildCritter(this.scene, `npc:${e.id}`, arch?.color ?? '#888888', height / 2)nnew file mode 100644@@@ -0,0 +1,3 @@++// file: 'apps/client/src/render/avatar/avatarLod.test.ts'++// covers: 'apps/client/src/render/avatar'++// Generated by node test/run.js --index; do not edit.
@@ -8,6 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -8,6 +8,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixed
### Fixed
+- **NPCs cost 150 draw calls in town.** Each avatar is about 14 meshes, so eleven townsfolk and wardens in view drew
+ 150 times a frame. Past 18 m an avatar, NPC or remote player, now draws as one merged copy of itself in its rest
+ pose and skips the animation work. It switches back to the full rig inside 16 m. In town, NPC draws went from 150
+ to 4.
- **Shadows halved the frame rate in town.** The shadow pass drew every caster in Scrap City again each frame (about
- **Shadows halved the frame rate in town.** The shadow pass drew every caster in Scrap City again each frame (about
390 more draw calls, ~720 a frame), and a test browser dropped from ~50 to ~10 fps. The shadow map is now redrawn
390 more draw calls, ~720 a frame), and a test browser dropped from ~50 to ~10 fps. The shadow map is now redrawn
every third frame, and at once whenever the shadow box moves, so no shadow jumps. That gives ~490 draw calls and
every third frame, and at once whenever the shadow box moves, so no shadow jumps. That gives ~490 draw calls and
@@ -1,13 +1,17 @@
@@ -1,13 +1,17 @@
import '@babylonjs/core/Rendering/outlineRenderer.js'
import '@babylonjs/core/Rendering/outlineRenderer.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import { Color3 } from '@babylonjs/core/Maths/math.color.js'
import type { Scene } from '@babylonjs/core/scene.js'
import type { Scene } from '@babylonjs/core/scene.js'
-import type { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
+import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
+import { VertexBuffer } from '@babylonjs/core/Buffers/buffer.js'
+import { Mesh } from '@babylonjs/core/Meshes/mesh.js'
+import type { StandardMaterial } from '@babylonjs/core/Materials/standardMaterial.js'
import type { ContentRegistry } from '@openvibe/content'
import type { ContentRegistry } from '@openvibe/content'
import type { Appearance } from '@openvibe/protocol'
import type { Appearance } from '@openvibe/protocol'
import { defaultAppearance } from '@openvibe/protocol'
import { defaultAppearance } from '@openvibe/protocol'
import { AvatarAnimator } from './animator.js'
import { AvatarAnimator } from './animator.js'
import { buildAvatarRig, type AvatarRig } from './rig.js'
import { buildAvatarRig, type AvatarRig } from './rig.js'
import { createHeldItemNode, type HeldItemNode } from '../heldItem.js'
import { createHeldItemNode, type HeldItemNode } from '../heldItem.js'
+import { materialFor } from '../sceneSetup.js'
/**
/**
* A complete animated player character: parametric rig + procedural
* A complete animated player character: parametric rig + procedural
@@ -35,6 +39,14 @@ export interface AvatarUpdate {
@@ -35,6 +39,14 @@ export interface AvatarUpdate {
const HURT_OVERLAY = new Color3(0.9, 0.12, 0.08)
const HURT_OVERLAY = new Color3(0.9, 0.12, 0.08)
+/**
+ * Past LOD_FAR metres from the camera an avatar draws as one merged copy of itself in its rest pose (one draw call,
+ * not ~14, and no animation work); it comes back to the full rig inside LOD_NEAR. Eleven townsfolk and wardens in
+ * view were 150 draw calls.
+ */
+const LOD_FAR = 18
+const LOD_NEAR = 16
+
export class Avatar {
export class Avatar {
private rig: AvatarRig
private rig: AvatarRig
private animator: AvatarAnimator
private animator: AvatarAnimator
@@ -48,9 +60,61 @@ export class Avatar {
@@ -48,9 +60,61 @@ export class Avatar {
private readonly content: ContentRegistry,
private readonly content: ContentRegistry,
private appearance: Appearance,
private appearance: Appearance,
private readonly name: string,
private readonly name: string,
+ /** Remote players and NPCs: draw far avatars as one merged mesh (see LOD_FAR). */
+ private readonly withLod = false,
) {
) {
this.rig = buildAvatarRig(scene, appearance, name)
this.rig = buildAvatarRig(scene, appearance, name)
this.animator = new AvatarAnimator(this.rig.joints)
this.animator = new AvatarAnimator(this.rig.joints)
+ if (withLod) this.lod = this.buildLod()
+ }
+
+ private lod: Mesh | null = null
+ private lodActive = false
+
+ /** The rig's meshes in their rest pose, each tinted its material's colour, merged under one white material. */
+ private buildLod(): Mesh | null {
+ const root = this.rig.joints.root
+ root.computeWorldMatrix(true)
+ const toRoot = root.getWorldMatrix().clone().invert()
+ const parts: Mesh[] = []
+ for (const part of root.getChildMeshes(false)) {
+ const material = part.material as StandardMaterial | null
+ if (!(part instanceof Mesh) || !part.getTotalVertices() || !material?.diffuseColor) continue
+ const copy = part.clone(`${this.name}:lodpart`, null, true)
+ copy.makeGeometryUnique()
+ copy.position.setAll(0)
+ copy.rotationQuaternion = null
+ copy.rotation.setAll(0)
+ copy.scaling.setAll(1)
+ copy.bakeTransformIntoVertices(part.computeWorldMatrix(true).multiply(toRoot))
+ 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)
+ parts.push(copy)
+ }
+ const merged = parts.length ? Mesh.MergeMeshes(parts, true, true) : null
+ if (!merged) return null
+ merged.name = `${this.name}:lod`
+ merged.material = materialFor(this.scene, '#ffffff')
+ merged.isPickable = false
+ merged.setEnabled(false)
+ return merged
+ }
+
+ /** Full rig near the camera, the merged copy far from it (with a little hysteresis). */
+ private useLod(x: number, y: number, z: number): boolean {
+ if (!this.lod) return false
+ const cam = this.scene.activeCamera
+ if (!cam) return this.lodActive
+ const d = Vector3.Distance(cam.globalPosition, new Vector3(x, y, z))
+ const far = this.lodActive ? d > LOD_NEAR : d > LOD_FAR
+ if (far !== this.lodActive) {
+ this.lodActive = far
+ this.rig.joints.root.setEnabled(!far)
+ this.lod.setEnabled(far)
+ }
+ return far
}
}
static appearanceOrDefault(a: Appearance | undefined): Appearance {
static appearanceOrDefault(a: Appearance | undefined): Appearance {
@@ -76,6 +140,9 @@ export class Avatar {
@@ -76,6 +140,9 @@ export class Avatar {
this.animator = new AvatarAnimator(this.rig.joints)
this.animator = new AvatarAnimator(this.rig.joints)
this.rig.setHeadVisible(this.headVisible)
this.rig.setHeadVisible(this.headVisible)
this.rig.setArmsVisible(this.armsVisible)
this.rig.setArmsVisible(this.armsVisible)
+ this.lod?.dispose()
+ this.lod = this.withLod ? this.buildLod() : null
+ this.lodActive = false
}
}
setHeadVisible(visible: boolean): void {
setHeadVisible(visible: boolean): void {
@@ -121,6 +188,11 @@ export class Avatar {
@@ -121,6 +188,11 @@ export class Avatar {
const j = this.rig.joints
const j = this.rig.joints
j.root.position.set(u.x, u.y, u.z)
j.root.position.set(u.x, u.y, u.z)
j.root.rotation.y = u.yaw
j.root.rotation.y = u.yaw
+ if (this.useLod(u.x, u.y, u.z)) {
+ this.lod!.position.set(u.x, u.y, u.z)
+ this.lod!.rotation.y = u.yaw
+ return
+ }
if (u.itemDef !== this.toolItemDef) {
if (u.itemDef !== this.toolItemDef) {
this.toolItemDef = u.itemDef
this.toolItemDef = u.itemDef
@@ -156,5 +228,6 @@ export class Avatar {
@@ -156,5 +228,6 @@ export class Avatar {
dispose(): void {
dispose(): void {
this.toolProp?.dispose()
this.toolProp?.dispose()
this.rig.dispose()
this.rig.dispose()
+ this.lod?.dispose()
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { NullEngine } from '@babylonjs/core/Engines/nullEngine.js'
+import { Scene } from '@babylonjs/core/scene.js'
+import { FreeCamera } from '@babylonjs/core/Cameras/freeCamera.js'
+import { Vector3 } from '@babylonjs/core/Maths/math.vector.js'
+import { VertexBuffer } from '@babylonjs/core/Buffers/buffer.js'
+import { createContent } from '@openvibe/content'
+import { defaultAppearance } from '@openvibe/protocol'
+import { Avatar, type AvatarUpdate } from './avatar.js'
+
+let engine: NullEngine
+let scene: Scene
+let camera: FreeCamera
+beforeEach(() => {
+ engine = new NullEngine()
+ scene = new Scene(engine)
+ camera = new FreeCamera('cam', new Vector3(0, 1.6, 0), scene)
+ scene.activeCamera = camera
+})
+afterEach(() => {
+ scene.dispose()
+ engine.dispose()
+})
+
+const at = (z: number): AvatarUpdate => ({
+ dt: 0.016,
+ time: 1,
+ x: 0,
+ y: 0,
+ z,
+ yaw: 0,
+ pitch: 0,
+ speed: 2,
+ grounded: true,
+})
+const lodOf = () => scene.getMeshByName('npc:a:lod')
+const rigShown = () =>
+ scene.meshes.some((m) => m.name.startsWith('npc:a:') && m.name !== 'npc:a:lod' && m.isEnabled())
+
+describe('avatar level of detail', () => {
+ it('draws a far avatar as one merged, vertex-coloured mesh and the full rig up close', () => {
+ const avatar = new Avatar(scene, createContent(), defaultAppearance(), 'npc:a', true)
+ const lod = lodOf()!
+ expect(lod).not.toBeNull()
+ expect(lod.isVerticesDataPresent(VertexBuffer.ColorKind)).toBe(true)
+
+ avatar.update(at(5))
+ expect(lod.isEnabled()).toBe(false)
+ expect(rigShown()).toBe(true)
+
+ avatar.update(at(30))
+ expect(lod.isEnabled()).toBe(true)
+ expect(rigShown()).toBe(false)
+ expect(lod.position.z).toBe(30)
+
+ // Hysteresis: between the two distances it stays as it is.
+ avatar.update(at(17))
+ expect(lod.isEnabled()).toBe(true)
+ avatar.update(at(10))
+ expect(lod.isEnabled()).toBe(false)
+ expect(rigShown()).toBe(true)
+
+ avatar.dispose()
+ expect(lodOf()).toBeNull()
+ })
+
+ it('builds nothing extra unless asked (the local body and the preview)', () => {
+ new Avatar(scene, createContent(), defaultAppearance(), 'npc:a')
+ expect(lodOf()).toBeNull()
+ })
+})
@@ -107,6 +107,7 @@ export class EntityView {
@@ -107,6 +107,7 @@ export class EntityView {
this.content,
this.content,
Avatar.appearanceOrDefault(e.appearance),
Avatar.appearanceOrDefault(e.appearance),
`player:${e.id}`,
`player:${e.id}`,
+ true,
)
)
// Collision mirror so local prediction blocks on other players the
// Collision mirror so local prediction blocks on other players the
// same way the server does (players collide with players).
// same way the server does (players collide with players).
@@ -139,7 +140,7 @@ export class EntityView {
@@ -139,7 +140,7 @@ export class EntityView {
const radius = arch?.radius ?? 0.35
const radius = arch?.radius ?? 0.35
const look = npcAppearance(arch?.id ?? '', e.id)
const look = npcAppearance(arch?.id ?? '', e.id)
if (look) {
if (look) {
- avatar = new Avatar(this.scene, this.content, look, `npc:${e.id}`)
+ avatar = new Avatar(this.scene, this.content, look, `npc:${e.id}`, true)
feetOffset = height / 2
feetOffset = height / 2
} else {
} else {
mesh = buildCritter(this.scene, `npc:${e.id}`, arch?.color ?? '#888888', height / 2)
mesh = buildCritter(this.scene, `npc:${e.id}`, arch?.color ?? '#888888', height / 2)
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+// file: 'apps/client/src/render/avatar/avatarLod.test.ts'
+// covers: 'apps/client/src/render/avatar'
+// Generated by node test/run.js --index; do not edit.