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)

OP OpenVibers committed 24 days ago · 4c7a119a6d · 5 files, +154 −2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8d428d1..b619d08 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 (about
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
ddiff --git a/apps/client/src/render/avatar/avatar.ts b/apps/client/src/render/avatar/avatar.ts
iindex 0ea3d9b..e66dc4e 100644
---- a/apps/client/src/render/avatar/avatar.ts
++++ b/apps/client/src/render/avatar/avatar.ts
@@@ -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: AvatarRig
private 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.joints
j.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()
}
}
ddiff --git a/apps/client/src/render/avatar/avatarLod.test.ts b/apps/client/src/render/avatar/avatarLod.test.ts
nnew file mode 100644
iindex 0000000..883abb5
---- /dev/null
++++ b/apps/client/src/render/avatar/avatarLod.test.ts
@@@ -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()
++ })
++})
ddiff --git a/apps/client/src/render/entityView.ts b/apps/client/src/render/entityView.ts
iindex 9c7e36c..7c15dff 100644
---- a/apps/client/src/render/entityView.ts
++++ b/apps/client/src/render/entityView.ts
@@@ -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.35
const 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)
ddiff --git a/test/apps__client__src__render__avatar__avatarLod.test.js b/test/apps__client__src__render__avatar__avatarLod.test.js
nnew file mode 100644
iindex 0000000..2840770
---- /dev/null
++++ b/test/apps__client__src__render__avatar__avatarLod.test.js
@@@ -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.