@worldmesh/runtime 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +21 -2
  2. package/dist/avatar/descriptor.d.ts +27 -0
  3. package/dist/avatar/descriptor.d.ts.map +1 -0
  4. package/dist/avatar/descriptor.js +41 -0
  5. package/dist/avatar/descriptor.js.map +1 -0
  6. package/dist/avatar/handoff.d.ts +29 -0
  7. package/dist/avatar/handoff.d.ts.map +1 -0
  8. package/dist/avatar/handoff.js +92 -0
  9. package/dist/avatar/handoff.js.map +1 -0
  10. package/dist/avatar/loader.d.ts +26 -0
  11. package/dist/avatar/loader.d.ts.map +1 -0
  12. package/dist/avatar/loader.js +177 -0
  13. package/dist/avatar/loader.js.map +1 -0
  14. package/dist/controls/input.d.ts +25 -0
  15. package/dist/controls/input.d.ts.map +1 -1
  16. package/dist/controls/input.js +60 -2
  17. package/dist/controls/input.js.map +1 -1
  18. package/dist/controls/touch.d.ts.map +1 -1
  19. package/dist/controls/touch.js +5 -0
  20. package/dist/controls/touch.js.map +1 -1
  21. package/dist/core/worldmesh.d.ts.map +1 -1
  22. package/dist/core/worldmesh.js +114 -8
  23. package/dist/core/worldmesh.js.map +1 -1
  24. package/dist/index.d.ts +10 -3
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/movement/controller.d.ts +10 -1
  29. package/dist/movement/controller.d.ts.map +1 -1
  30. package/dist/movement/controller.js +61 -4
  31. package/dist/movement/controller.js.map +1 -1
  32. package/dist/net/adapter.d.ts +3 -14
  33. package/dist/net/adapter.d.ts.map +1 -1
  34. package/dist/net/adapter.js.map +1 -1
  35. package/dist/net/presence.d.ts +68 -0
  36. package/dist/net/presence.d.ts.map +1 -0
  37. package/dist/net/presence.js +425 -0
  38. package/dist/net/presence.js.map +1 -0
  39. package/dist/player/avatar.d.ts +4 -0
  40. package/dist/player/avatar.d.ts.map +1 -1
  41. package/dist/player/avatar.js +97 -4
  42. package/dist/player/avatar.js.map +1 -1
  43. package/dist/player/player.d.ts +13 -0
  44. package/dist/player/player.d.ts.map +1 -1
  45. package/dist/player/player.js +44 -5
  46. package/dist/player/player.js.map +1 -1
  47. package/dist/portals/portals.d.ts +4 -1
  48. package/dist/portals/portals.d.ts.map +1 -1
  49. package/dist/portals/portals.js +6 -2
  50. package/dist/portals/portals.js.map +1 -1
  51. package/dist/types.d.ts +78 -4
  52. package/dist/types.d.ts.map +1 -1
  53. package/dist/ui/overlay.d.ts +7 -0
  54. package/dist/ui/overlay.d.ts.map +1 -1
  55. package/dist/ui/overlay.js +62 -7
  56. package/dist/ui/overlay.js.map +1 -1
  57. package/package.json +4 -1
  58. package/src/avatar/descriptor.ts +56 -0
  59. package/src/avatar/handoff.ts +91 -0
  60. package/src/avatar/loader.ts +184 -0
  61. package/src/controls/input.ts +61 -2
  62. package/src/controls/touch.ts +5 -0
  63. package/src/core/worldmesh.ts +110 -8
  64. package/src/index.ts +21 -1
  65. package/src/movement/controller.ts +59 -5
  66. package/src/net/adapter.ts +3 -14
  67. package/src/net/presence.ts +498 -0
  68. package/src/player/avatar.ts +109 -4
  69. package/src/player/player.ts +42 -3
  70. package/src/portals/portals.ts +6 -2
  71. package/src/types.ts +77 -4
  72. package/src/ui/overlay.ts +60 -7
@@ -0,0 +1,498 @@
1
+ import { CanvasTexture, Group, Mesh, MeshBasicMaterial, Sprite, SpriteMaterial, Vector3, type Object3D, type Scene } from 'three';
2
+ import { animateDefaultAvatar, createDefaultAvatar, setAvatarExpression } from '../player/avatar.js';
3
+ import type { NetworkAdapter, PeerBody, PlayerState, WorldMeshHandle } from '../types.js';
4
+
5
+ /** The relay WorldMesh hosts (workers/presence). Worlds can point at their own. */
6
+ export const DEFAULT_PRESENCE_SERVER = 'wss://worldmesh-presence.elias-willnat.workers.dev';
7
+
8
+ export interface PresenceOptions {
9
+ /** Full WebSocket URL of the room, e.g. `wss://relay.example/world`. */
10
+ url: string;
11
+ scene: Scene;
12
+ /** Players in the room including this one, or null while disconnected. */
13
+ onCount?: (count: number | null) => void;
14
+ getName?: () => string | null;
15
+ /** Made-up name shown instead of the username in private mode. */
16
+ getAlias?: () => string | null;
17
+ /**
18
+ * Someone new just started walking here, first seen at (x, z). Not called
19
+ * for the people already in the room when this visitor joins.
20
+ */
21
+ onArrive?: (x: number, z: number) => void;
22
+ /** Called with each remote figure as it is made, e.g. to add an outline. */
23
+ onFigure?: (root: Object3D) => void;
24
+ }
25
+
26
+ /** How often the local position goes out, per second. */
27
+ const SEND_RATE = 10;
28
+ /** Resend at least this often while standing still, so late joiners see us. */
29
+ const HEARTBEAT_MS = 3000;
30
+ const MAX_BACKOFF_MS = 30_000;
31
+
32
+ interface Remote {
33
+ root: Object3D;
34
+ target: Vector3;
35
+ targetYaw: number;
36
+ yaw: number;
37
+ /** Smoothed horizontal speed, estimated from how far the avatar moves. */
38
+ speed: number;
39
+ /** Standing on something (floor, platform, step) rather than in the air. */
40
+ grounded: boolean;
41
+ /** Text currently drawn on the tag. */
42
+ label: string;
43
+ tag: Sprite;
44
+ /** Seconds since this figure first appeared; drives spawn fade-in. */
45
+ appear: number;
46
+ /** What they just said, stuck above their head until it runs out. */
47
+ bubble: Sprite | null;
48
+ bubbleLeft: number;
49
+ }
50
+
51
+ /**
52
+ * For peers that do not send `g`: a height change smaller than this between
53
+ * two updates is standing, not jumping or falling.
54
+ */
55
+ const GROUND_HOLD = 0.02;
56
+
57
+ /** The size of a remote figure for collisions: the default player's. */
58
+ const BODY_RADIUS = 0.35;
59
+ const BODY_HEIGHT = 1.8;
60
+
61
+ /** Height of the name tag's centre above the avatar's feet. */
62
+ const TAG_HEIGHT = 2.15;
63
+
64
+ /** Where a peer is. `g` is 1 on the ground, 0 in the air; older peers leave it out. */
65
+ interface PeerState {
66
+ p: [number, number, number];
67
+ r: number;
68
+ e?: string;
69
+ n?: string;
70
+ a?: string;
71
+ g?: number;
72
+ }
73
+
74
+ type ServerMessage =
75
+ | { t: 'welcome'; id: string; peers: (PeerState & { id: string })[] }
76
+ | ({ t: 's'; id: string } & PeerState)
77
+ | { t: 'c'; id: string; m: string }
78
+ | { t: 'leave'; id: string };
79
+
80
+ /**
81
+ * Shows everyone else who is walking the same room, through the runtime's
82
+ * NetworkAdapter seam. The connection is best-effort: if the presence server
83
+ * is unreachable the lobby simply stays single-player.
84
+ */
85
+ export class Presence implements NetworkAdapter {
86
+ private socket: WebSocket | null = null;
87
+ private selfId: string | null = null;
88
+ private remotes = new Map<string, Remote>();
89
+ private group = new Group();
90
+ private unsubscribe: (() => void) | null = null;
91
+ private backoff = 1000;
92
+ private reconnectTimer = 0;
93
+ private closed = false;
94
+ private lastSent = 0;
95
+ private lastPayload = '';
96
+
97
+ private url: string;
98
+ private onCount: (count: number | null) => void;
99
+ private getName: () => string | null;
100
+ private getAlias: () => string | null;
101
+ private onArrive: (x: number, z: number) => void;
102
+ private onFigure: (root: Object3D) => void;
103
+
104
+ constructor(options: PresenceOptions) {
105
+ this.url = options.url;
106
+ this.onCount = options.onCount ?? (() => {});
107
+ this.getName = options.getName ?? (() => null);
108
+ this.getAlias = options.getAlias ?? (() => null);
109
+ this.onArrive = options.onArrive ?? (() => {});
110
+ this.onFigure = options.onFigure ?? (() => {});
111
+ this.group.name = 'worldmesh:remote-players';
112
+ options.scene.add(this.group);
113
+ }
114
+
115
+ attach(world: WorldMeshHandle): void {
116
+ this.unsubscribe = world.on('update', ({ dt, state }) => {
117
+ this.sendLocalState(state);
118
+ this.update(dt);
119
+ });
120
+ window.addEventListener('pagehide', this.handlePageHide);
121
+ window.addEventListener('pageshow', this.handlePageShow);
122
+ this.connect();
123
+ }
124
+
125
+ sendLocalState(state: PlayerState): void {
126
+ const socket = this.socket;
127
+ if (!socket || socket.readyState !== WebSocket.OPEN || !this.selfId) return;
128
+
129
+ const now = performance.now();
130
+ if (now - this.lastSent < 1000 / SEND_RATE) return;
131
+
132
+ const [x, y, z] = state.position;
133
+ const payload = JSON.stringify({ t: 's', p: [round(x), round(y), round(z)], r: round(state.facing), e: state.expression, n: this.getName() ?? '', a: this.getAlias() ?? '', g: state.onGround || state.flying ? 1 : 0 });
134
+ if (payload === this.lastPayload && now - this.lastSent < HEARTBEAT_MS) return;
135
+
136
+ socket.send(payload);
137
+ this.lastPayload = payload;
138
+ this.lastSent = now;
139
+ }
140
+
141
+ /** Say something. Everyone else gets a bubble; this client draws its own. */
142
+ say(text: string): void {
143
+ const socket = this.socket;
144
+ const line = text.replace(/[\u0000-\u001f]/g, '').trim().slice(0, 80);
145
+ if (!line || !socket || socket.readyState !== WebSocket.OPEN) return;
146
+ socket.send(JSON.stringify({ t: 'c', m: line }));
147
+ }
148
+
149
+ /** Everyone else's body where it is drawn, so walking into them stops where they appear to be. */
150
+ bodies(): PeerBody[] {
151
+ const bodies: PeerBody[] = [];
152
+ for (const { root } of this.remotes.values()) {
153
+ const { x, y, z } = root.position;
154
+ bodies.push({ x, y, z, radius: BODY_RADIUS, height: BODY_HEIGHT });
155
+ }
156
+ return bodies;
157
+ }
158
+
159
+ /** Ease remote avatars toward their last known position. Driven by the world's update event. */
160
+ update(dt: number): void {
161
+ const blend = 1 - Math.exp(-dt * 10);
162
+ for (const remote of this.remotes.values()) {
163
+ const before = remote.root.position.clone();
164
+ remote.root.position.lerp(remote.target, blend);
165
+
166
+ let delta = remote.targetYaw - remote.yaw;
167
+ delta = Math.atan2(Math.sin(delta), Math.cos(delta));
168
+ remote.yaw += delta * blend;
169
+ remote.root.rotation.y = remote.yaw;
170
+
171
+ const moved = Math.hypot(remote.root.position.x - before.x, remote.root.position.z - before.z);
172
+ remote.speed += (moved / Math.max(dt, 1e-4) - remote.speed) * blend;
173
+ animateDefaultAvatar(remote.root, { dt, speed: remote.speed, grounded: remote.grounded });
174
+
175
+ if (remote.bubble) {
176
+ remote.bubbleLeft -= dt;
177
+ remote.bubble.material.opacity = remote.bubbleLeft < 1 ? Math.max(0, remote.bubbleLeft) : 1;
178
+ if (remote.bubbleLeft <= 0) dropBubble(remote);
179
+ }
180
+
181
+ // The frame that crosses the end of the fade lands on fully opaque, however long it was.
182
+ const fading = remote.appear < APPEAR_FADE;
183
+ remote.appear += dt;
184
+ if (fading) {
185
+ const t = Math.min(1, remote.appear / APPEAR_FADE);
186
+ setFigureOpacity(remote.root, t * t * (3 - 2 * t));
187
+ }
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Leave and re-enter the room as a new peer. Everyone else sees this
193
+ * visitor leave and someone new arrive, with nothing linking the two.
194
+ */
195
+ rejoin(): void {
196
+ if (this.closed) return;
197
+ window.clearTimeout(this.reconnectTimer);
198
+ this.disconnect();
199
+ this.backoff = 1000;
200
+ this.connect();
201
+ }
202
+
203
+ detach(): void {
204
+ this.closed = true;
205
+ window.clearTimeout(this.reconnectTimer);
206
+ window.removeEventListener('pagehide', this.handlePageHide);
207
+ window.removeEventListener('pageshow', this.handlePageShow);
208
+ this.unsubscribe?.();
209
+ this.disconnect();
210
+ this.group.removeFromParent();
211
+ }
212
+
213
+ private connect(): void {
214
+ if (this.closed || this.socket) return;
215
+
216
+ let socket: WebSocket;
217
+ try {
218
+ socket = new WebSocket(this.url);
219
+ } catch {
220
+ this.scheduleReconnect();
221
+ return;
222
+ }
223
+ this.socket = socket;
224
+
225
+ socket.addEventListener('message', (event) => {
226
+ if (typeof event.data !== 'string') return;
227
+ let message: ServerMessage;
228
+ try {
229
+ message = JSON.parse(event.data) as ServerMessage;
230
+ } catch {
231
+ return;
232
+ }
233
+ this.handleMessage(message);
234
+ });
235
+
236
+ socket.addEventListener('close', () => {
237
+ if (this.socket !== socket) return;
238
+ this.socket = null;
239
+ this.selfId = null;
240
+ this.clearRemotes();
241
+ this.onCount(null);
242
+ this.scheduleReconnect();
243
+ });
244
+ }
245
+
246
+ private disconnect(): void {
247
+ const socket = this.socket;
248
+ this.socket = null;
249
+ this.selfId = null;
250
+ socket?.close(1000, 'bye');
251
+ this.clearRemotes();
252
+ this.onCount(null);
253
+ }
254
+
255
+ private scheduleReconnect(): void {
256
+ if (this.closed) return;
257
+ window.clearTimeout(this.reconnectTimer);
258
+ this.reconnectTimer = window.setTimeout(() => this.connect(), this.backoff);
259
+ this.backoff = Math.min(this.backoff * 2, MAX_BACKOFF_MS);
260
+ }
261
+
262
+ private handleMessage(message: ServerMessage): void {
263
+ switch (message.t) {
264
+ case 'welcome':
265
+ this.selfId = message.id;
266
+ this.backoff = 1000;
267
+ this.lastPayload = '';
268
+ this.lastSent = 0;
269
+ for (const peer of message.peers) this.upsert(peer.id, peer, true);
270
+ break;
271
+ case 's':
272
+ if (message.id !== this.selfId) this.upsert(message.id, message, false);
273
+ break;
274
+ case 'c': {
275
+ const remote = message.id === this.selfId ? undefined : this.remotes.get(message.id);
276
+ if (remote && typeof message.m === 'string') showBubble(remote, message.m);
277
+ break;
278
+ }
279
+ case 'leave': {
280
+ const remote = this.remotes.get(message.id);
281
+ if (remote) disposeObject(remote.root);
282
+ this.remotes.delete(message.id);
283
+ break;
284
+ }
285
+ }
286
+ this.onCount(this.remotes.size + 1);
287
+ }
288
+
289
+ private upsert(id: string, state: PeerState, snap: boolean): void {
290
+ const { p, r: yaw, e: expression, g } = state;
291
+ if (!Array.isArray(p) || p.length !== 3 || !p.every(Number.isFinite) || !Number.isFinite(yaw)) return;
292
+ const label = nameLabel(state.n, state.a);
293
+
294
+ let remote = this.remotes.get(id);
295
+ if (!remote) {
296
+ const root = createDefaultAvatar();
297
+ const tag = createNameTag(GUEST);
298
+ tag.position.y = TAG_HEIGHT;
299
+ root.add(tag);
300
+ this.onFigure(root);
301
+ this.group.add(root);
302
+ remote = { root, target: new Vector3(p[0], p[1], p[2]), targetYaw: yaw, yaw, speed: 0, grounded: true, label: GUEST, tag, appear: 0, bubble: null, bubbleLeft: 0 };
303
+ this.remotes.set(id, remote);
304
+ setFigureOpacity(root, 0);
305
+ if (!snap) this.onArrive(p[0], p[2]);
306
+ snap = true;
307
+ }
308
+ // Peers that do not say whether they are grounded count as standing when
309
+ // their height holds still, so a raised floor like the spawn platform
310
+ // does not leave them in the jump pose.
311
+ remote.grounded = g === 0 || g === 1 ? g === 1 : p[1] < 0.05 || Math.abs(p[1] - remote.target.y) < GROUND_HOLD;
312
+ remote.target.set(p[0], p[1], p[2]);
313
+ remote.targetYaw = yaw;
314
+ if (typeof expression === 'string') setAvatarExpression(remote.root, expression);
315
+ if (label !== remote.label) {
316
+ remote.label = label;
317
+ disposeObject(remote.tag);
318
+ remote.tag = createNameTag(label);
319
+ remote.tag.position.y = TAG_HEIGHT;
320
+ remote.root.add(remote.tag);
321
+ }
322
+ // A long jump is a teleport or respawn, not a sprint: do not animate it.
323
+ if (remote.root.position.distanceTo(remote.target) > 6) {
324
+ snap = true;
325
+ remote.appear = 0;
326
+ setFigureOpacity(remote.root, 0);
327
+ }
328
+ if (snap) {
329
+ remote.root.position.copy(remote.target);
330
+ remote.yaw = yaw;
331
+ }
332
+ }
333
+
334
+ private clearRemotes(): void {
335
+ for (const remote of this.remotes.values()) disposeObject(remote.root);
336
+ this.remotes.clear();
337
+ }
338
+
339
+ // Leave the room promptly when the tab navigates away (e.g. through a door),
340
+ // and rejoin if the browser restores the page from its back/forward cache.
341
+ private handlePageHide = (): void => {
342
+ window.clearTimeout(this.reconnectTimer);
343
+ this.disconnect();
344
+ };
345
+
346
+ private handlePageShow = (event: PageTransitionEvent): void => {
347
+ if (event.persisted && !this.closed) {
348
+ this.backoff = 1000;
349
+ this.connect();
350
+ }
351
+ };
352
+ }
353
+
354
+ function round(value: number): number {
355
+ return Math.round(value * 100) / 100;
356
+ }
357
+
358
+ /** How long a newly appeared figure takes to go from transparent to opaque. */
359
+ const APPEAR_FADE = 0.55;
360
+
361
+ const GUEST = 'Guest';
362
+
363
+ /** Ease every mesh on an avatar toward a shared opacity. */
364
+ function setFigureOpacity(root: Object3D, amount: number): void {
365
+ const solid = amount >= 0.999;
366
+ let outlined = false;
367
+ root.traverse((child) => {
368
+ if (child instanceof Mesh && child.userData.stroke === true) outlined = true;
369
+ });
370
+ root.traverse((child) => {
371
+ if (!(child instanceof Mesh)) return;
372
+ for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
373
+ const stroke = material.userData.stroke === true;
374
+ const face = material instanceof MeshBasicMaterial && !stroke;
375
+ material.transparent = face || !solid;
376
+ material.opacity = amount;
377
+ // An outline sits behind the body, so a fading body still writes depth
378
+ // to keep the outline's far side from showing through it as a black blob.
379
+ material.depthWrite = stroke ? solid : !face && (solid || outlined);
380
+ material.needsUpdate = true;
381
+ }
382
+ });
383
+ for (const child of root.children) {
384
+ if (!(child instanceof Sprite)) continue;
385
+ child.material.opacity = amount;
386
+ child.material.transparent = true;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * '@username' for a signed-in visitor, their made-up name in private mode,
392
+ * or 'Guest'. Only real usernames get the '@'.
393
+ */
394
+ function nameLabel(name: unknown, alias: unknown): string {
395
+ if (typeof name === 'string' && name) return `@${name}`;
396
+ if (typeof alias === 'string' && alias) return alias;
397
+ return GUEST;
398
+ }
399
+
400
+ /** A camera-facing label over a remote avatar. Guests are drawn dimmer. */
401
+ const BUBBLE_LIFE = 7;
402
+
403
+ /** Stick a line of text over a remote figure. The next line replaces it. */
404
+ function showBubble(remote: Remote, text: string): void {
405
+ dropBubble(remote);
406
+ const bubble = createSpeechBubble(text);
407
+ bubble.position.y = TAG_HEIGHT + 0.42;
408
+ remote.root.add(bubble);
409
+ remote.bubble = bubble;
410
+ remote.bubbleLeft = BUBBLE_LIFE;
411
+ }
412
+
413
+ function dropBubble(remote: Remote): void {
414
+ const bubble = remote.bubble;
415
+ if (!bubble) return;
416
+ bubble.material.map?.dispose();
417
+ bubble.material.dispose();
418
+ bubble.removeFromParent();
419
+ remote.bubble = null;
420
+ }
421
+
422
+ /** A white speech bubble. One line, cut off if they wrote a novel. */
423
+ function createSpeechBubble(text: string): Sprite {
424
+ const scale = 2;
425
+ const font = `600 ${20 * scale}px system-ui, -apple-system, sans-serif`;
426
+ const canvas = document.createElement('canvas');
427
+ const context = canvas.getContext('2d')!;
428
+ context.font = font;
429
+ const padX = 16 * scale;
430
+ const height = 40 * scale;
431
+ const maxWidth = 280 * scale;
432
+ const measured = context.measureText(text).width;
433
+ canvas.width = Math.ceil(Math.min(maxWidth, measured) + padX * 2);
434
+ canvas.height = height;
435
+
436
+ context.font = font;
437
+ context.fillStyle = '#ffffff';
438
+ context.beginPath();
439
+ context.roundRect(0, 0, canvas.width, height, 14 * scale);
440
+ context.fill();
441
+ context.fillStyle = '#111111';
442
+ context.textAlign = 'center';
443
+ context.textBaseline = 'middle';
444
+ context.fillText(text, canvas.width / 2, height / 2, maxWidth);
445
+
446
+ const texture = new CanvasTexture(canvas);
447
+ const sprite = new Sprite(new SpriteMaterial({ map: texture, depthWrite: false, transparent: true }));
448
+ const worldHeight = 0.34;
449
+ sprite.scale.set((worldHeight * canvas.width) / height, worldHeight, 1);
450
+ sprite.name = 'worldmesh:speech';
451
+ sprite.center.set(0.5, 0);
452
+ return sprite;
453
+ }
454
+
455
+ function createNameTag(text: string): Sprite {
456
+ const scale = 2; // Canvas pixels per CSS pixel, for crisp text.
457
+ const font = `600 ${22 * scale}px system-ui, -apple-system, sans-serif`;
458
+ const canvas = document.createElement('canvas');
459
+ const context = canvas.getContext('2d')!;
460
+ context.font = font;
461
+ const padX = 12 * scale;
462
+ const height = 36 * scale;
463
+ canvas.width = Math.ceil(context.measureText(text).width + padX * 2);
464
+ canvas.height = height;
465
+
466
+ context.font = font;
467
+ context.fillStyle = 'rgba(0, 0, 0, 0.55)';
468
+ context.beginPath();
469
+ context.roundRect(0, 0, canvas.width, height, height / 2);
470
+ context.fill();
471
+ context.fillStyle = text === GUEST ? 'rgba(255, 255, 255, 0.7)' : '#ffffff';
472
+ context.textAlign = 'center';
473
+ context.textBaseline = 'middle';
474
+ context.fillText(text, canvas.width / 2, height / 2 + scale);
475
+
476
+ const texture = new CanvasTexture(canvas);
477
+ texture.anisotropy = 4;
478
+ const sprite = new Sprite(new SpriteMaterial({ map: texture, depthWrite: false, transparent: true }));
479
+ const worldHeight = 0.26;
480
+ sprite.scale.set((worldHeight * canvas.width) / height, worldHeight, 1);
481
+ sprite.name = 'worldmesh:name-tag';
482
+ return sprite;
483
+ }
484
+
485
+ function disposeObject(root: Object3D): void {
486
+ root.traverse((child) => {
487
+ if (child instanceof Sprite) {
488
+ child.material.map?.dispose();
489
+ child.material.dispose();
490
+ } else if (child instanceof Mesh) {
491
+ child.geometry.dispose();
492
+ const material = child.material;
493
+ if (Array.isArray(material)) material.forEach((m) => m.dispose());
494
+ else material.dispose();
495
+ }
496
+ });
497
+ root.removeFromParent();
498
+ }
@@ -6,7 +6,9 @@ import {
6
6
  MathUtils,
7
7
  Mesh,
8
8
  MeshBasicMaterial,
9
- MeshStandardMaterial,
9
+ MeshToonMaterial,
10
+ NearestFilter,
11
+ type Material,
10
12
  RingGeometry,
11
13
  SphereGeometry,
12
14
  SplineCurve,
@@ -79,6 +81,106 @@ export interface AvatarMotion {
79
81
  heading?: number;
80
82
  }
81
83
 
84
+ /** A hard two-tone ramp. Only the darkest sliver is shadow, so the gray stays small. */
85
+ function toonSteps(shadow: string, light: string): CanvasTexture {
86
+ const canvas = document.createElement('canvas');
87
+ canvas.width = 8;
88
+ canvas.height = 1;
89
+ const ctx = canvas.getContext('2d')!;
90
+ ctx.fillStyle = light;
91
+ ctx.fillRect(0, 0, 8, 1);
92
+ ctx.fillStyle = shadow;
93
+ ctx.fillRect(0, 0, 2, 1);
94
+ const texture = new CanvasTexture(canvas);
95
+ texture.magFilter = NearestFilter;
96
+ texture.minFilter = NearestFilter;
97
+ texture.colorSpace = SRGBColorSpace;
98
+ texture.needsUpdate = true;
99
+ return texture;
100
+ }
101
+
102
+ const APPEAR_KEY = 'worldmeshAppear';
103
+
104
+ /**
105
+ * Scatter the surface into points that fill in as `uFill` goes from 0 to 1.
106
+ * Materials on one figure share a uniform so the body and face appear together.
107
+ */
108
+ function attachAppear(material: Material, shared?: { value: number }): { value: number } {
109
+ const fill = shared ?? { value: 1 };
110
+ material.userData[APPEAR_KEY] = fill;
111
+ material.customProgramCacheKey = () => 'worldmesh-appear';
112
+ material.onBeforeCompile = (shader) => {
113
+ shader.uniforms.uFill = fill;
114
+ const appearFns = `
115
+ varying vec3 vAppearPos;
116
+ uniform float uFill;
117
+ float appearHash(vec3 p) {
118
+ p = fract(p * vec3(443.897, 441.423, 437.195));
119
+ p += dot(p, p.yzx + 19.19);
120
+ return fract((p.x + p.y) * p.z);
121
+ }
122
+ float appearNoise(vec3 p) {
123
+ return appearHash(p) * 0.65 + appearHash(p * 2.7 + 4.2) * 0.35;
124
+ }`;
125
+ shader.vertexShader = shader.vertexShader
126
+ .replace('#include <common>', `#include <common>\n${appearFns}`)
127
+ .replace(
128
+ '#include <begin_vertex>',
129
+ `#include <begin_vertex>
130
+ float appearWarp = 1.0 - uFill;
131
+ vec3 appearWorld = (modelMatrix * vec4(position, 1.0)).xyz;
132
+ float appearShift = (appearNoise(appearWorld * 5.5) * 2.0 - 1.0) * 0.38 * appearWarp;
133
+ transformed += (inverse(modelMatrix) * vec4(0.0, appearShift, 0.0, 0.0)).xyz;
134
+ vAppearPos = appearWorld;`,
135
+ );
136
+ shader.fragmentShader = shader.fragmentShader
137
+ .replace(
138
+ '#include <common>',
139
+ `#include <common>
140
+ ${appearFns}`,
141
+ )
142
+ .replace(
143
+ '#include <dithering_fragment>',
144
+ `if (uFill < 0.999 && appearHash(floor(vAppearPos * 70.0)) > uFill) discard;
145
+ #include <dithering_fragment>`,
146
+ );
147
+ };
148
+ return fill;
149
+ }
150
+
151
+ /** Tint a default avatar. Custom bodies are left alone. */
152
+ export function setAvatarColor(root: Object3D, color: string): boolean {
153
+ let found = false;
154
+ root.traverse((child) => {
155
+ const mesh = child as Mesh;
156
+ if (!mesh.isMesh) return;
157
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
158
+ for (const material of materials) {
159
+ if (!(material instanceof MeshToonMaterial)) continue;
160
+ material.color.set(color);
161
+ found = true;
162
+ }
163
+ });
164
+ return found;
165
+ }
166
+
167
+ /** Drive the point-fill on a default avatar. Returns false for anything else. */
168
+ export function setAvatarAppear(root: Object3D, amount: number): boolean {
169
+ let found = false;
170
+ root.traverse((child) => {
171
+ const mesh = child as Mesh;
172
+ if (!mesh.isMesh) return;
173
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
174
+ for (const material of materials) {
175
+ const fill = material.userData[APPEAR_KEY] as { value: number } | undefined;
176
+ if (!fill) continue;
177
+ fill.value = amount;
178
+ found = true;
179
+ }
180
+ });
181
+ return found;
182
+ }
183
+
82
184
  const HEAD_RADIUS = 0.47;
83
185
  const HEAD_Y = 1.33;
84
186
  /** How far the arms hang away from the body at rest, in radians. */
@@ -88,11 +190,13 @@ export function createDefaultAvatar(height = 1.8): Group {
88
190
  const root = new Group();
89
191
  root.name = 'worldmesh:avatar';
90
192
 
91
- // Soft glossy white; a little self-light keeps the shaded side from going grey.
92
- const skin = new MeshStandardMaterial({ color: 0xf7f7f7, emissive: 0x1e1e1e, roughness: 0.42, metalness: 0 });
193
+ // Two flat tones: white in the light, light gray in the shade.
194
+ const skin = new MeshToonMaterial({ color: 0xffffff, gradientMap: toonSteps('#d5d5d5', '#ffffff') });
195
+ const skinFill = attachAppear(skin);
93
196
  const shaded = (geometry: BufferGeometry) => {
94
197
  const mesh = new Mesh(geometry, skin);
95
- mesh.castShadow = true;
198
+ mesh.castShadow = false;
199
+ mesh.receiveShadow = false;
96
200
  return mesh;
97
201
  };
98
202
 
@@ -131,6 +235,7 @@ export function createDefaultAvatar(height = 1.8): Group {
131
235
  polygonOffset: true,
132
236
  polygonOffsetFactor: -1,
133
237
  });
238
+ attachAppear(faceMaterial, skinFill);
134
239
  const face = new Mesh(faceGeometry(), faceMaterial);
135
240
  face.name = 'worldmesh:avatar-face';
136
241
  // Built facing +Z; turn it to the front of the figure.