minecraft-renderer 0.1.35 → 0.1.37

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.35",
3
+ "version": "0.1.37",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,6 +32,10 @@ export const defaultWorldRendererConfig = {
32
32
  autoLowerRenderDistance: false,
33
33
 
34
34
  // Rendering engine settings
35
+ /** Face shading: vanilla Minecraft vs higher-contrast client look */
36
+ shadingTheme: 'high-contrast' as 'vanilla' | 'high-contrast',
37
+ /** Synced from player reactive state (dimension / nether) — consumed by mesher */
38
+ cardinalLight: 'default' as string,
35
39
  dayCycle: true,
36
40
  smoothLighting: true,
37
41
  enableLighting: true,
@@ -32,6 +32,7 @@ export const getInitialPlayerState = (): PlayerStateReactive => proxy({
32
32
  itemUsageTicks: 0,
33
33
  username: '',
34
34
  onlineMode: false,
35
+ cardinalLight: 'default',
35
36
  lightingDisabled: false,
36
37
  shouldHideHand: false,
37
38
  heldItemMain: undefined,
@@ -124,6 +124,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
124
124
  soundSystem: SoundSystem | undefined
125
125
 
126
126
  abstract changeBackgroundColor(color: [number, number, number]): void
127
+ abstract changeCardinalLight(cardinalLight: string): void
127
128
 
128
129
  /** Override in subclass to check if any enabled module requires heightmap data */
129
130
  protected anyModuleRequiresHeightmap(): boolean {
@@ -310,6 +311,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
310
311
  this.onReactivePlayerStateUpdated('backgroundColor', (value) => {
311
312
  this.changeBackgroundColor(value)
312
313
  })
314
+ this.onReactivePlayerStateUpdated('cardinalLight', (value) => {
315
+ this.changeCardinalLight(value)
316
+ })
313
317
  }
314
318
 
315
319
  watchReactiveConfig() {
@@ -549,6 +553,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
549
553
  enableLighting: this.worldRendererConfig.enableLighting,
550
554
  skyLight,
551
555
  smoothLighting: this.worldRendererConfig.smoothLighting,
556
+ shadingTheme: this.worldRendererConfig.shadingTheme,
557
+ cardinalLight: this.worldRendererConfig.cardinalLight,
552
558
  outputFormat: this.outputFormat,
553
559
  // textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
554
560
  debugModelVariant: this.worldRendererConfig.debugModelVariant,
@@ -395,7 +395,16 @@ function renderElement(world: World, cursor: Vec3, element: BlockElement, doAO:
395
395
  const aos: number[] = []
396
396
  const neighborPos = position.plus(new Vec3(...dir))
397
397
  // 10%
398
- const baseLight = world.getLight(neighborPos, undefined, undefined, block.name) / 15
398
+ const { smoothLighting, shadingTheme, cardinalLight } = world.config
399
+ const faceLight = world.getLight(neighborPos, undefined, undefined, block.name)
400
+ const sideShading = (shadingTheme === 'high-contrast')
401
+ ? (0.8 + 0.5 * Math.max(0, 0.66 * dir[0] + 0.66 * dir[1] + 0.33 * dir[2])) // old directional light behavior
402
+ : (
403
+ cardinalLight === 'nether'
404
+ ? (0.5 + Math.abs(0.1 * dir[0] + 0.4 * dir[1] + 0.3 * dir[2]))
405
+ : (0.75 + 0.25 * dir[1] + 0.05 * (Math.abs(dir[2]) - 3 * Math.abs(dir[0])))
406
+ )
407
+ const baseLight = sideShading * faceLight / 15
399
408
  for (const pos of corners) {
400
409
  let vertex = [
401
410
  (pos[0] ? maxx : minx),
@@ -429,8 +438,6 @@ function renderElement(world: World, cursor: Vec3, element: BlockElement, doAO:
429
438
  }
430
439
 
431
440
  let light = 1
432
- const { smoothLighting } = world.config
433
- // const smoothLighting = true
434
441
  if (doAO) {
435
442
  const dx = pos[0] * 2 - 1
436
443
  const dy = pos[1] * 2 - 1
@@ -442,7 +449,7 @@ function renderElement(world: World, cursor: Vec3, element: BlockElement, doAO:
442
449
  const side2 = world.getBlock(cursor.offset(...side2Dir))
443
450
  const corner = world.getBlock(cursor.offset(...cornerDir))
444
451
 
445
- let cornerLightResult = baseLight * 15
452
+ let cornerLightResult = faceLight
446
453
 
447
454
  if (smoothLighting) {
448
455
  const dirVec = new Vec3(...dir)
@@ -459,7 +466,7 @@ function renderElement(world: World, cursor: Vec3, element: BlockElement, doAO:
459
466
  const cornerLightDir = getVec(new Vec3(...cornerDir))
460
467
  const cornerLight = world.getLight(cursor.plus(cornerLightDir))
461
468
  // interpolate
462
- const lights = [side1Light, side2Light, cornerLight, baseLight * 15]
469
+ const lights = [side1Light, side2Light, cornerLight, faceLight]
463
470
  cornerLightResult = lights.reduce((acc, cur) => acc + cur, 0) / lights.length
464
471
  }
465
472
 
@@ -470,8 +477,10 @@ function renderElement(world: World, cursor: Vec3, element: BlockElement, doAO:
470
477
  // TODO: correctly interpolate ao light based on pos (evaluate once for each corner of the block)
471
478
 
472
479
  const ao = (side1Block && side2Block) ? 0 : (3 - (side1Block + side2Block + cornerBlock))
480
+ const ao_bias = (shadingTheme === 'high-contrast') ? 0.25 : 0.4
481
+ const ao_scale = (shadingTheme === 'high-contrast') ? 0.25 : 0.2
473
482
  // todo light should go upper on lower blocks
474
- light = (ao + 1) / 4 * (cornerLightResult / 15)
483
+ light = sideShading * (ao * ao_scale + ao_bias) * (cornerLightResult / 15)
475
484
  aos.push(ao)
476
485
 
477
486
  // Log AO and light for this corner (corner index is aos.length - 1)
@@ -11,7 +11,9 @@ export const defaultMesherConfig = {
11
11
  worldMinY: 0,
12
12
  enableLighting: true,
13
13
  skyLight: 15,
14
- smoothLighting: false,
14
+ smoothLighting: true,
15
+ shadingTheme: 'high-contrast' as 'vanilla' | 'high-contrast',
16
+ cardinalLight: 'default' as string,
15
17
  outputFormat: 'threeJs' as 'threeJs' | 'webgpu',
16
18
  // textureSize: 1024, // for testing
17
19
  debugModelVariant: undefined as undefined | number[],
@@ -39,6 +39,8 @@ export const getInitialPlayerState = () => proxy({
39
39
  itemUsageTicks: 0,
40
40
  username: '',
41
41
  onlineMode: false,
42
+ /** Dimension ambient lighting preset (e.g. nether) — from login/respawn dimension data when available */
43
+ cardinalLight: 'default' as string,
42
44
  lightingDisabled: false,
43
45
  shouldHideHand: false,
44
46
  heldItemMain: undefined as HandItemBlock | undefined,
@@ -64,7 +64,7 @@ export class ChunkMeshManager {
64
64
 
65
65
  constructor (
66
66
  public worldRenderer: WorldRendererThree,
67
- public scene: THREE.Group,
67
+ public scene: THREE.Object3D,
68
68
  public material: THREE.Material,
69
69
  public worldHeight: number,
70
70
  viewDistance = 3,
@@ -1,7 +1,6 @@
1
1
  //@ts-nocheck
2
2
  import * as THREE from 'three'
3
3
  import * as tweenJs from '@tweenjs/tween.js'
4
- import PrismarineItem from 'prismarine-item'
5
4
  import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
6
5
  import { BlockModel } from 'mc-assets'
7
6
  import { DebugGui } from '../lib/DebugGui'
@@ -17,8 +16,14 @@ import { getThreeBlockModelGroup } from '../mesher/standaloneRenderer'
17
16
  import { IndexedData } from 'minecraft-data'
18
17
  import { WorldRendererConfig } from '../graphicsBackend'
19
18
  import { computeCameraBob, type CameraBobInput } from '../lib/cameraBobbing'
19
+ import { getFirstPersonItemSpecificProps, getHandItemRenderKey } from './holdingBlockItemIdentity'
20
20
 
21
21
  const _tempMat = new THREE.Matrix4()
22
+ const wrapPi = (a: number) => {
23
+ a = (a + Math.PI) % (Math.PI * 2)
24
+ if (a < 0) a += Math.PI * 2
25
+ return a - Math.PI
26
+ }
22
27
 
23
28
  // Vanilla renderPlayerArm transform chain
24
29
  function buildBareHandMatrix(swingProgress: number, equipProgress: number): THREE.Matrix4 {
@@ -94,6 +99,7 @@ export default class HoldingBlock implements IHoldingBlock {
94
99
  equipProgress = 0 // 0 = fully visible, 1 = hidden
95
100
  stopUpdate = false
96
101
  lastHeldItem: HandItemBlock | undefined
102
+ lastHeldItemRenderKey: string | undefined
97
103
  currentDisplayType: 'hand' | 'item' | 'block' = 'hand'
98
104
  isSwinging = false
99
105
  nextIterStopCallbacks: Array<() => void> | undefined
@@ -116,6 +122,7 @@ export default class HoldingBlock implements IHoldingBlock {
116
122
 
117
123
  constructor(public worldRenderer: WorldRendererThree, public offHand = false) {
118
124
  this.initCameraGroup()
125
+ this.swingAnimator = new HandSwingAnimator()
119
126
  this.unsubs.push(
120
127
  this.worldRenderer.onReactivePlayerStateUpdated('heldItemMain', () => {
121
128
  if (!this.offHand) {
@@ -332,44 +339,32 @@ export default class HoldingBlock implements IHoldingBlock {
332
339
  }
333
340
 
334
341
  isDifferentItem(block: HandItemBlock | undefined) {
335
- const Item = PrismarineItem(this.worldRenderer.version)
336
- if (!this.lastHeldItem) {
337
- return true
338
- }
339
- if (this.lastHeldItem.name !== block?.name) {
340
- return true
341
- }
342
- // eslint-disable-next-line sonarjs/prefer-single-boolean-return
343
- if (!Item.equal(this.lastHeldItem.fullItem, block?.fullItem ?? {}) || JSON.stringify(this.lastHeldItem.fullItem.components) !== JSON.stringify(block?.fullItem?.components)) {
344
- return true
345
- }
346
-
347
- return false
342
+ return this.lastHeldItemRenderKey !== getHandItemRenderKey(this.worldRenderer, block)
348
343
  }
349
344
 
350
345
  updateCameraGroup() {
351
346
  if (this.stopUpdate) return
352
347
  const { camera } = this
353
348
 
354
- // Hand rotation momentum (xBob/yBob) — vanilla Minecraft inertia effect
355
- // Use base rotation from CameraShake (actual player view angles)
356
349
  const now = performance.now()
357
350
  const baseRotation = this.worldRenderer.cameraShake.getBaseRotation()
358
351
  const actualPitch = baseRotation.pitch
359
352
  const actualYaw = baseRotation.yaw
353
+
360
354
  if (this.lastBobUpdateTime === 0) {
361
355
  this.xBob = actualPitch
362
356
  this.yBob = actualYaw
363
- this.lastBobUpdateTime = now
364
357
  } else {
365
358
  const dt = Math.min((now - this.lastBobUpdateTime) / 1000, 0.1)
366
- this.lastBobUpdateTime = now
367
- const factor = 1 - Math.pow(0.5, dt * 20)
368
- this.xBob += (actualPitch - this.xBob) * factor
369
- this.yBob += (actualYaw - this.yBob) * factor
359
+ const pitchFactor = 1 - Math.pow(0.5, dt * 28)
360
+ const yawFactor = 1 - Math.pow(0.5, dt * 36)
361
+ this.xBob += (actualPitch - this.xBob) * pitchFactor
362
+ this.yBob += wrapPi(actualYaw - this.yBob) * yawFactor
370
363
  }
371
- const pitchOffset = (actualPitch - this.xBob) * -0.1
372
- const yawOffset = (actualYaw - this.yBob) * -0.1
364
+
365
+ this.lastBobUpdateTime = now
366
+ const pitchOffset = (actualPitch - this.xBob) * -0.05
367
+ const yawOffset = wrapPi(actualYaw - this.yBob) * -0.035
373
368
 
374
369
  this.cameraGroup.position.copy(camera.position)
375
370
  this.cameraGroup.rotation.copy(camera.rotation)
@@ -431,11 +426,7 @@ export default class HoldingBlock implements IHoldingBlock {
431
426
  const result = this.worldRenderer.entities.getItemMesh({
432
427
  ...handItem.fullItem,
433
428
  itemId: handItem.id,
434
- }, {
435
- 'minecraft:display_context': 'firstperson',
436
- 'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks,
437
- 'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks,
438
- }, false, this.lastItemModelName)
429
+ }, getFirstPersonItemSpecificProps(this.worldRenderer), false, this.lastItemModelName)
439
430
  if (result) {
440
431
  const { mesh: itemMesh, isBlock, modelName } = result
441
432
  if (isBlock) {
@@ -487,8 +478,11 @@ export default class HoldingBlock implements IHoldingBlock {
487
478
  this.holdingBlock?.removeFromParent()
488
479
  this.holdingBlock = undefined
489
480
  this.currentDisplayType = 'hand'
490
- this.swingAnimator?.stopSwing()
491
- this.swingAnimator = undefined
481
+ const swingAnimator = this.swingAnimator
482
+ swingAnimator?.stopSwing()
483
+ if (swingAnimator) {
484
+ swingAnimator.type = 'hand'
485
+ }
492
486
  this.idleAnimator = undefined
493
487
  return
494
488
  }
@@ -516,10 +510,14 @@ export default class HoldingBlock implements IHoldingBlock {
516
510
 
517
511
  switchRequest = 0
518
512
  async setNewItem(handItem?: HandItemBlock) {
519
- if (!this.isDifferentItem(handItem)) return
513
+ const nextRenderKey = getHandItemRenderKey(this.worldRenderer, handItem)
514
+ const itemChanged = this.lastHeldItemRenderKey !== nextRenderKey
515
+ this.lastHeldItem = handItem
516
+ if (!itemChanged) return
517
+
518
+ this.lastHeldItemRenderKey = nextRenderKey
520
519
  this.lastItemModelName = undefined
521
520
  const switchRequest = ++this.switchRequest
522
- this.lastHeldItem = handItem
523
521
 
524
522
  let playAppearAnimation = false
525
523
  if (this.holdingBlock) {
@@ -535,7 +533,11 @@ export default class HoldingBlock implements IHoldingBlock {
535
533
 
536
534
  if (!handItem) {
537
535
  this.currentDisplayType = 'hand'
538
- this.swingAnimator = undefined
536
+ const swingAnimator = this.swingAnimator
537
+ swingAnimator?.stopSwing()
538
+ if (swingAnimator) {
539
+ swingAnimator.type = 'hand'
540
+ }
539
541
  this.idleAnimator = undefined
540
542
  this.blockSwapAnimation = undefined
541
543
  return
@@ -553,8 +555,10 @@ export default class HoldingBlock implements IHoldingBlock {
553
555
  await this.playBlockSwapAnimation('appeared')
554
556
  }
555
557
 
556
- this.swingAnimator = new HandSwingAnimator()
557
- this.swingAnimator.type = result.type
558
+ const swingAnimator = this.swingAnimator
559
+ if (swingAnimator) {
560
+ swingAnimator.type = result.type
561
+ }
558
562
  // Idle animation disabled — walking bob is handled by vanilla bobView applied to cameraGroup
559
563
  this.idleAnimator = undefined
560
564
  }
@@ -0,0 +1,43 @@
1
+ //@ts-nocheck
2
+ import { expect, test, vi } from 'vitest'
3
+ import { getHandItemRenderKey } from './holdingBlockItemIdentity'
4
+
5
+ test('hand item render key ignores live use state but keeps first-person display context', () => {
6
+ const getItemRenderData = vi.fn(() => ({ modelName: 'item/bow' }))
7
+ const worldRenderer = {
8
+ playerStateReactive: {
9
+ itemUsageTicks: 20,
10
+ },
11
+ resourcesManager: {
12
+ currentResources: {},
13
+ },
14
+ getItemRenderData,
15
+ } as any
16
+
17
+ const handItem = {
18
+ type: 'item',
19
+ id: 261,
20
+ name: 'minecraft:bow',
21
+ fullItem: {
22
+ count: 1,
23
+ },
24
+ } as const
25
+
26
+ const activeKey = getHandItemRenderKey(worldRenderer, handItem)
27
+ worldRenderer.playerStateReactive.itemUsageTicks = 0
28
+ const idleKey = getHandItemRenderKey(worldRenderer, handItem)
29
+
30
+ expect(activeKey).toBe(idleKey)
31
+ expect(getItemRenderData).toHaveBeenNthCalledWith(1, {
32
+ ...handItem.fullItem,
33
+ itemId: handItem.id,
34
+ }, {
35
+ 'minecraft:display_context': 'firstperson',
36
+ })
37
+ expect(getItemRenderData).toHaveBeenNthCalledWith(2, {
38
+ ...handItem.fullItem,
39
+ itemId: handItem.id,
40
+ }, {
41
+ 'minecraft:display_context': 'firstperson',
42
+ })
43
+ })
@@ -0,0 +1,30 @@
1
+ //@ts-nocheck
2
+ import type { HandItemBlock, ItemSpecificContextProperties } from '../playerState/types'
3
+ import type { WorldRendererThree } from './worldRendererThree'
4
+
5
+ export const getFirstPersonItemSpecificProps = (worldRenderer: WorldRendererThree): ItemSpecificContextProperties => ({
6
+ 'minecraft:display_context': 'firstperson',
7
+ 'minecraft:use_duration': worldRenderer.playerStateReactive.itemUsageTicks,
8
+ 'minecraft:using_item': !!worldRenderer.playerStateReactive.itemUsageTicks,
9
+ })
10
+
11
+ const getFirstPersonItemIdentityProps = (): ItemSpecificContextProperties => ({
12
+ 'minecraft:display_context': 'firstperson',
13
+ })
14
+
15
+ export const getHandItemRenderKey = (worldRenderer: WorldRendererThree, handItem?: HandItemBlock) => {
16
+ if (!handItem) return 'empty'
17
+ if (handItem.type === 'hand') return 'hand'
18
+
19
+ const itemIdentifier = handItem.name ?? (handItem.id !== undefined ? `#${handItem.id}` : 'unknown')
20
+ if (!worldRenderer.resourcesManager.currentResources) {
21
+ return `${handItem.type}:${itemIdentifier}`
22
+ }
23
+
24
+ const renderData = worldRenderer.getItemRenderData({
25
+ ...handItem.fullItem,
26
+ itemId: handItem.id,
27
+ }, getFirstPersonItemIdentityProps())
28
+
29
+ return `${handItem.type}:${itemIdentifier}:${renderData.modelName}`
30
+ }
@@ -1,7 +1,6 @@
1
1
  //@ts-nocheck
2
2
  import * as THREE from 'three'
3
3
  import * as tweenJs from '@tweenjs/tween.js'
4
- import PrismarineItem from 'prismarine-item'
5
4
  import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
6
5
  import { BlockModel } from 'mc-assets'
7
6
  import { DebugGui } from '../lib/DebugGui'
@@ -17,6 +16,7 @@ import { getThreeBlockModelGroup } from '../mesher/standaloneRenderer'
17
16
  import { IndexedData } from 'minecraft-data'
18
17
  import { WorldRendererConfig } from '../graphicsBackend'
19
18
  import { IHoldingBlock } from './holdingBlockTypes'
19
+ import { getFirstPersonItemSpecificProps, getHandItemRenderKey } from './holdingBlockItemIdentity'
20
20
 
21
21
  const rotationPositionData = {
22
22
  itemRight: {
@@ -103,6 +103,7 @@ export default class HoldingBlockLegacy implements IHoldingBlock {
103
103
  camera = new THREE.PerspectiveCamera(75, 1, 0.1, 100)
104
104
  stopUpdate = false
105
105
  lastHeldItem: HandItemBlock | undefined
106
+ lastHeldItemRenderKey: string | undefined
106
107
  isSwinging = false
107
108
  nextIterStopCallbacks: Array<() => void> | undefined
108
109
  idleAnimator: HandIdleAnimator | undefined
@@ -304,19 +305,7 @@ export default class HoldingBlockLegacy implements IHoldingBlock {
304
305
  }
305
306
 
306
307
  isDifferentItem(block: HandItemBlock | undefined) {
307
- const Item = PrismarineItem(this.worldRenderer.version)
308
- if (!this.lastHeldItem) {
309
- return true
310
- }
311
- if (this.lastHeldItem.name !== block?.name) {
312
- return true
313
- }
314
- // eslint-disable-next-line sonarjs/prefer-single-boolean-return
315
- if (!Item.equal(this.lastHeldItem.fullItem, block?.fullItem ?? {}) || JSON.stringify(this.lastHeldItem.fullItem.components) !== JSON.stringify(block?.fullItem?.components)) {
316
- return true
317
- }
318
-
319
- return false
308
+ return this.lastHeldItemRenderKey !== getHandItemRenderKey(this.worldRenderer, block)
320
309
  }
321
310
 
322
311
  updateCameraGroup() {
@@ -355,11 +344,7 @@ export default class HoldingBlockLegacy implements IHoldingBlock {
355
344
  const result = this.worldRenderer.entities.getItemMesh({
356
345
  ...handItem.fullItem,
357
346
  itemId: handItem.id,
358
- }, {
359
- 'minecraft:display_context': 'firstperson',
360
- 'minecraft:use_duration': this.worldRenderer.playerStateReactive.itemUsageTicks,
361
- 'minecraft:using_item': !!this.worldRenderer.playerStateReactive.itemUsageTicks,
362
- }, false, this.lastItemModelName)
347
+ }, getFirstPersonItemSpecificProps(this.worldRenderer), false, this.lastItemModelName)
363
348
  if (result) {
364
349
  const { mesh: itemMesh, isBlock, modelName } = result
365
350
  if (isBlock) {
@@ -421,10 +406,14 @@ export default class HoldingBlockLegacy implements IHoldingBlock {
421
406
 
422
407
  switchRequest = 0
423
408
  async setNewItem(handItem?: HandItemBlock) {
424
- if (!this.isDifferentItem(handItem)) return
409
+ const nextRenderKey = getHandItemRenderKey(this.worldRenderer, handItem)
410
+ const itemChanged = this.lastHeldItemRenderKey !== nextRenderKey
411
+ this.lastHeldItem = handItem
412
+ if (!itemChanged) return
413
+
414
+ this.lastHeldItemRenderKey = nextRenderKey
425
415
  this.lastItemModelName = undefined
426
416
  const switchRequest = ++this.switchRequest
427
- this.lastHeldItem = handItem
428
417
  let playAppearAnimation = false
429
418
  if (this.holdingBlock) {
430
419
  // play disappear animation
@@ -472,7 +461,8 @@ export default class HoldingBlockLegacy implements IHoldingBlock {
472
461
  await this.playBlockSwapAnimation('appeared')
473
462
  }
474
463
 
475
- this.swingAnimator = new HandSwingAnimator(this.holdingBlockInnerGroup)
464
+ this.swingAnimator ??= new HandSwingAnimator(this.holdingBlockInnerGroup)
465
+ this.swingAnimator.setHandMesh(this.holdingBlockInnerGroup)
476
466
  this.swingAnimator.type = result.type
477
467
  if (this.config.viewBobbing) {
478
468
  this.idleAnimator = new HandIdleAnimator(this.holdingBlockInnerGroup, this.worldRenderer.playerStateReactive)
@@ -783,9 +773,9 @@ class HandSwingAnimator {
783
773
  private lastTime = 0
784
774
  private isAnimating = false
785
775
  private stopRequested = false
786
- private readonly originalRotation: THREE.Euler
787
- private readonly originalPosition: THREE.Vector3
788
- private readonly originalScale: THREE.Vector3
776
+ private originalRotation: THREE.Euler
777
+ private originalPosition: THREE.Vector3
778
+ private originalScale: THREE.Vector3
789
779
 
790
780
  readonly debugParams = {
791
781
  // Animation timing
@@ -849,6 +839,13 @@ class HandSwingAnimator {
849
839
  // this.debugGui.activate()
850
840
  }
851
841
 
842
+ setHandMesh(handMesh: THREE.Object3D) {
843
+ this.handMesh = handMesh
844
+ this.originalRotation.copy(handMesh.rotation)
845
+ this.originalPosition.copy(handMesh.position)
846
+ this.originalScale.copy(handMesh.scale)
847
+ }
848
+
852
849
  update() {
853
850
  if (!this.isAnimating && !this.debugParams.animationStage) {
854
851
  // If not animating, ensure we're at original position
@@ -58,7 +58,7 @@ export class SciFiWorldRevealModule implements RendererModuleController {
58
58
  // Store original methods for patching
59
59
  private originalFinishChunk: ((chunkKey: string) => void) | null = null
60
60
  private originalDestroy: (() => void) | null = null
61
- private originalSceneAdd: ((...object: THREE.Object3D[]) => THREE.Group) | null = null
61
+ private originalSceneAdd: ((...object: THREE.Object3D[]) => THREE.Scene) | null = null
62
62
  private originalHandleWorkerMessage: ((data: { geometry: MesherGeometryOutput; key: string; type: string }) => void) | null = null
63
63
 
64
64
  private configEnabled = true
@@ -163,7 +163,7 @@ export class SciFiWorldRevealModule implements RendererModuleController {
163
163
 
164
164
  // Patch scene.add to intercept mesh additions
165
165
  this.originalSceneAdd = wr.scene.add.bind(wr.scene)
166
- wr.scene.add = (...objects: THREE.Object3D[]): THREE.Group => {
166
+ wr.scene.add = (...objects: THREE.Object3D[]): THREE.Scene => {
167
167
  // Call original add first
168
168
  const result = this.originalSceneAdd!(...objects)
169
169
 
@@ -174,7 +174,7 @@ export class SceneOrigin {
174
174
  /** Untrack an Object3D and remove it from the scene */
175
175
  removeAndUntrack(obj: Object3D): void {
176
176
  this.untrack(obj)
177
- this.scene.remove(obj)
177
+ obj.removeFromParent()
178
178
  }
179
179
 
180
180
  /** Untrack an Object3D and all its descendants, then remove from the scene */
@@ -182,7 +182,7 @@ export class SceneOrigin {
182
182
  obj.traverse((child) => {
183
183
  this.untrack(child)
184
184
  })
185
- this.scene.remove(obj)
185
+ obj.removeFromParent()
186
186
  }
187
187
 
188
188
  /** Get stored world position for a tracked object */