minecraft-renderer 0.1.43 → 0.1.44

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.
@@ -0,0 +1,36 @@
1
+ //@ts-nocheck
2
+ export { MENU_BACKGROUND_MC_VERSION } from './shared'
3
+ export type { MenuBackgroundView } from './activeView'
4
+ export { resizeMenuBackgroundCamera } from './activeView'
5
+ export type { MenuBackgroundMode, MenuBackgroundOptions } from './types'
6
+ export { resolveMenuBackgroundMode } from './types'
7
+ export { ClassicMenuBackground } from './classic'
8
+ export type {
9
+ FuturisticSceneId,
10
+ FuturisticCameraId,
11
+ FuturisticMenuBackgroundOptions,
12
+ MinecraftBlockGroupId
13
+ } from './futuristic'
14
+ export {
15
+ FuturisticMenuBackground,
16
+ FUTURISTIC_SCENE_IDS,
17
+ FUTURISTIC_CAMERA_IDS,
18
+ FUTURISTIC_SCENE_LABELS,
19
+ FUTURISTIC_CAMERA_LABELS,
20
+ MINECRAFT_BLOCK_GROUPS,
21
+ MINECRAFT_BLOCK_GROUP_IDS,
22
+ MINECRAFT_BLOCK_GROUP_LABELS
23
+ } from './futuristic'
24
+ export { WorldBlocksMenuBackground } from './worldBlocks'
25
+ export { MenuBackgroundRenderer } from './renderer'
26
+ export {
27
+ MENU_BACKGROUND_OPTION_DEFAULTS,
28
+ MENU_BACKGROUND_MOTION_DEFAULTS,
29
+ menuBackgroundSpeedToMultiplier
30
+ } from './config'
31
+ export {
32
+ RENDERER_DEFAULT_OPTIONS,
33
+ RENDERER_OPTIONS_META,
34
+ RENDERER_RENDER_GUI_SECTIONS
35
+ } from './defaultOptions'
36
+ export type { RendererDefaultOptionKey, RendererOptionMeta } from './defaultOptions'
@@ -0,0 +1,97 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import type { GraphicsInitOptions } from '../../graphicsBackend/types'
4
+ import type { DocumentRenderer } from '../documentRenderer'
5
+ import { ClassicMenuBackground } from './classic'
6
+ import { FuturisticMenuBackground } from './futuristic'
7
+ import { WorldBlocksMenuBackground } from './worldBlocks'
8
+ import type { MenuBackgroundView } from './activeView'
9
+ import type { MenuBackgroundOptions } from './types'
10
+ import { resolveMenuBackgroundMode } from './types'
11
+
12
+ /**
13
+ * Orchestrates main-menu background rendering (dispatches to classic / futuristic / world-blocks).
14
+ */
15
+ export class MenuBackgroundRenderer {
16
+ private active?: MenuBackgroundView
17
+ private readonly abortController = new AbortController()
18
+ private readonly mode: ReturnType<typeof resolveMenuBackgroundMode>
19
+ private lastFrameTime = 0
20
+
21
+ constructor(
22
+ private readonly documentRenderer: DocumentRenderer,
23
+ private readonly options: GraphicsInitOptions,
24
+ menuBackgroundOptions: MenuBackgroundOptions = {},
25
+ singleFileBuild = false
26
+ ) {
27
+ this.mode = resolveMenuBackgroundMode(menuBackgroundOptions, singleFileBuild)
28
+ }
29
+
30
+ /** Active futuristic instance when that style is running. */
31
+ get futuristic(): FuturisticMenuBackground | undefined {
32
+ return this.active instanceof FuturisticMenuBackground ? this.active : undefined
33
+ }
34
+
35
+ get scene(): THREE.Scene | undefined {
36
+ return this.active?.scene
37
+ }
38
+
39
+ get camera(): THREE.PerspectiveCamera | undefined {
40
+ return this.active?.camera
41
+ }
42
+
43
+ async start(menuBackgroundOptions: MenuBackgroundOptions = {}) {
44
+ this.active = this.createImplementation(menuBackgroundOptions)
45
+ await this.active.init()
46
+
47
+ if (this.active.scene.background instanceof THREE.Color) {
48
+ this.documentRenderer.renderer.setClearColor(this.active.scene.background)
49
+ }
50
+
51
+ this.lastFrameTime = performance.now()
52
+ this.documentRenderer.render = (sizeChanged = false) => {
53
+ const now = performance.now()
54
+ const dt = Math.min((now - this.lastFrameTime) / 1000, 0.05)
55
+ this.lastFrameTime = now
56
+
57
+ const view = this.active
58
+ if (!view) return
59
+
60
+ view.update(dt, sizeChanged)
61
+ this.documentRenderer.renderer.render(view.scene, view.camera)
62
+ }
63
+ }
64
+
65
+ private createImplementation(options: MenuBackgroundOptions): MenuBackgroundView {
66
+ switch (this.mode) {
67
+ case 'futuristic':
68
+ return new FuturisticMenuBackground(
69
+ this.documentRenderer,
70
+ {
71
+ useMinecraftTextures: options.useMinecraftTextures,
72
+ initialScene: options.futuristicScene,
73
+ initialCamera: options.futuristicCamera,
74
+ initialBlockGroup: options.futuristicBlockGroup,
75
+ initialCameraSpeed: options.futuristicCameraSpeed,
76
+ initialBlockSpeed: options.futuristicBlockSpeed,
77
+ resourcesManager: options.resourcesManager
78
+ },
79
+ this.abortController.signal
80
+ )
81
+ case 'worldBlocks':
82
+ return new WorldBlocksMenuBackground(
83
+ this.documentRenderer,
84
+ this.options,
85
+ this.abortController.signal
86
+ )
87
+ default:
88
+ return new ClassicMenuBackground(this.documentRenderer)
89
+ }
90
+ }
91
+
92
+ dispose() {
93
+ this.active?.dispose()
94
+ this.active = undefined
95
+ this.abortController.abort()
96
+ }
97
+ }
@@ -0,0 +1,3 @@
1
+ //@ts-nocheck
2
+ /** Minecraft version used when the menu background loads block assets (world-blocks / textured cubes). */
3
+ export const MENU_BACKGROUND_MC_VERSION = '1.21.4'
@@ -0,0 +1,37 @@
1
+ //@ts-nocheck
2
+ import type { ResourcesManager } from '../../resourcesManager/resourcesManager'
3
+ import type { FuturisticCameraId, FuturisticSceneId, MinecraftBlockGroupId } from './futuristic'
4
+ import { MENU_BACKGROUND_OPTION_DEFAULTS } from './config'
5
+
6
+ export type { FuturisticCameraId, FuturisticSceneId, MinecraftBlockGroupId } from './futuristic'
7
+
8
+ export type MenuBackgroundMode = 'classic' | 'futuristic' | 'worldBlocks'
9
+
10
+ export interface MenuBackgroundOptions {
11
+ /** Visual style. Defaults to {@link MENU_BACKGROUND_OPTION_DEFAULTS.mode}, or `worldBlocks` in single-file build. */
12
+ mode?: MenuBackgroundMode
13
+ /** Futuristic style: load block atlas and render textured cubes (requires assets / mcData). */
14
+ useMinecraftTextures?: boolean
15
+ futuristicScene?: FuturisticSceneId
16
+ futuristicCamera?: FuturisticCameraId
17
+ /** Block pool when {@link useMinecraftTextures} is enabled. */
18
+ futuristicBlockGroup?: MinecraftBlockGroupId
19
+ /** Camera path speed (1 = 100%). */
20
+ futuristicCameraSpeed?: number
21
+ /** Block fly-through + sky drift speed (1 = 100%). */
22
+ futuristicBlockSpeed?: number
23
+ /**
24
+ * Optional shared resource manager (e.g. appViewer.resourcesManager).
25
+ * Caller should run `updateAssetsData` after mcData is loaded when using textured cubes.
26
+ */
27
+ resourcesManager?: ResourcesManager
28
+ }
29
+
30
+ export function resolveMenuBackgroundMode(
31
+ options?: MenuBackgroundOptions,
32
+ singleFileBuild = false
33
+ ): MenuBackgroundMode {
34
+ if (options?.mode) return options.mode
35
+ if (singleFileBuild) return 'worldBlocks'
36
+ return MENU_BACKGROUND_OPTION_DEFAULTS.mode
37
+ }
@@ -0,0 +1,144 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import { Vec3 } from 'vec3'
4
+ import * as tweenJs from '@tweenjs/tween.js'
5
+ import { getSyncWorld } from '../../playground/shared'
6
+ import type { GraphicsInitOptions } from '../../graphicsBackend/types'
7
+ import { WorldRendererCommon } from '../../lib/worldrendererCommon'
8
+ import { defaultWorldRendererConfig, getDefaultRendererState } from '../../graphicsBackend/config'
9
+ import { ResourcesManager, ResourcesManagerTransferred } from '../../resourcesManager/resourcesManager'
10
+ import { getInitialPlayerStateRenderer } from '../../graphicsBackend/playerState'
11
+ import { WorldRendererThree } from '../worldRendererThree'
12
+ import type { DocumentRenderer } from '../documentRenderer'
13
+ import { MENU_BACKGROUND_MC_VERSION } from './shared'
14
+ import { WorldView } from '../../worldView'
15
+ import type { MenuBackgroundView } from './activeView'
16
+ import { resizeMenuBackgroundCamera } from './activeView'
17
+
18
+ /**
19
+ * Menu background built from a wall of random stained-glass blocks (single-file / demo style).
20
+ */
21
+ export class WorldBlocksMenuBackground implements MenuBackgroundView {
22
+ private _scene: THREE.Scene
23
+ private _camera: THREE.PerspectiveCamera
24
+
25
+ get scene() { return this._scene }
26
+ get camera() { return this._camera }
27
+
28
+ private worldRenderer?: WorldRendererCommon | WorldRendererThree
29
+ WorldRendererClass = WorldRendererThree
30
+
31
+ constructor(
32
+ private readonly documentRenderer: DocumentRenderer,
33
+ private readonly options: GraphicsInitOptions,
34
+ private readonly abortSignal: AbortSignal
35
+ ) {
36
+ this._scene = new THREE.Scene()
37
+ this._scene.background = new THREE.Color(0x32_45_68)
38
+ this._camera = new THREE.PerspectiveCamera(
39
+ 85,
40
+ documentRenderer.canvas.width / documentRenderer.canvas.height,
41
+ 0.05,
42
+ 1000
43
+ )
44
+ this.camera.position.set(0, 0, 0)
45
+ this.camera.rotation.set(0, 0, 0)
46
+ }
47
+
48
+ async init() {
49
+ const version = MENU_BACKGROUND_MC_VERSION
50
+ const fullResourceManager = new ResourcesManager()
51
+ fullResourceManager.currentConfig = { version, noInventoryGui: true }
52
+ await fullResourceManager.updateAssetsData?.({})
53
+ if (this.abortSignal.aborted) return
54
+
55
+ console.time('load menu background scene')
56
+ const world = getSyncWorld(version)
57
+ const PrismarineBlock = require('prismarine-block')
58
+ const Block = PrismarineBlock(version)
59
+ const mcData = (globalThis as any).mcData
60
+ const fullBlocks = mcData.blocksArray.filter((block: { name: string, defaultState: number }) => {
61
+ if (!block.name.includes('stained_glass')) return false
62
+ const b = Block.fromStateId(block.defaultState, 0)
63
+ if (b.shapes?.length !== 1) return false
64
+ const shape = b.shapes[0]
65
+ return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1
66
+ })
67
+
68
+ const Z = -15
69
+ const sizeX = 100
70
+ const sizeY = 100
71
+ for (let x = -sizeX; x < sizeX; x++) {
72
+ for (let y = -sizeY; y < sizeY; y++) {
73
+ const block = fullBlocks[Math.floor(Math.random() * fullBlocks.length)]
74
+ world.setBlockStateId(new Vec3(x, y, Z), block.defaultState)
75
+ }
76
+ }
77
+
78
+ this._camera.updateProjectionMatrix()
79
+ this._camera.position.set(0.5, sizeY / 2 + 0.5, 0.5)
80
+ this._camera.rotation.set(0, 0, 0)
81
+ const initPos = new Vec3(...this._camera.position.toArray())
82
+ const worldView = new WorldView(world, 2, initPos)
83
+ if (this.abortSignal.aborted) return
84
+
85
+ this.worldRenderer = new this.WorldRendererClass(
86
+ this.documentRenderer.renderer,
87
+ this.options,
88
+ {
89
+ version,
90
+ worldView,
91
+ inWorldRenderingConfig: defaultWorldRendererConfig,
92
+ playerStateReactive: getInitialPlayerStateRenderer().reactive,
93
+ rendererState: getDefaultRendererState().reactive,
94
+ nonReactiveState: getDefaultRendererState().nonReactive,
95
+ resourcesManager: fullResourceManager as ResourcesManagerTransferred
96
+ }
97
+ )
98
+
99
+ if (this.worldRenderer instanceof WorldRendererThree) {
100
+ this._scene = this.worldRenderer.realScene
101
+ this._camera = this.worldRenderer.camera
102
+ }
103
+
104
+ void worldView.init(initPos)
105
+ await this.worldRenderer.waitForChunksToRender()
106
+ if (this.abortSignal.aborted) return
107
+
108
+ this.setupMouseParallax()
109
+ console.timeEnd('load menu background scene')
110
+ }
111
+
112
+ update(_dt: number, sizeChanged: boolean) {
113
+ if (sizeChanged) {
114
+ resizeMenuBackgroundCamera(this.camera, this.documentRenderer.canvas)
115
+ }
116
+ }
117
+
118
+ dispose() {
119
+ this.worldRenderer?.destroy()
120
+ this.worldRenderer = undefined
121
+ this._scene.clear()
122
+ }
123
+
124
+ private setupMouseParallax() {
125
+ const camera = this._camera
126
+ const initX = camera.position.x
127
+ const initY = camera.position.y
128
+ let prevTween: tweenJs.Tween<THREE.Vector3> | undefined
129
+
130
+ document.body.addEventListener('pointermove', (e) => {
131
+ if (e.pointerType !== 'mouse') return
132
+ const SCALE = 0.2
133
+ const xRel = e.clientX / window.innerWidth - 0.5
134
+ const yRel = -(e.clientY / window.innerHeight - 0.5)
135
+ prevTween?.stop()
136
+ prevTween = new tweenJs.Tween(camera.position).to({
137
+ x: initX + xRel * SCALE,
138
+ y: initY + yRel * SCALE
139
+ }, 0)
140
+ prevTween.start()
141
+ camera.updateProjectionMatrix()
142
+ }, { signal: this.abortSignal })
143
+ }
144
+ }
@@ -2,6 +2,15 @@
2
2
  import * as THREE from 'three'
3
3
  import { createCanvas } from '../lib/utils'
4
4
 
5
+ /**
6
+ * Limits label texture resolution on high-DPR devices (sprite still sizes in screen px via Three.js;
7
+ * main win is fewer canvas pixels / less GPU memory — especially on iOS).
8
+ */
9
+ const LABEL_CANVAS_MAX_DEVICE_PIXEL_RATIO = 1
10
+
11
+ /** Distance label repaints when this bucket (meters) changes — fewer canvas uploads while moving. */
12
+ const DISTANCE_LABEL_STEP_M = 10
13
+
5
14
  // Centralized visual configuration (in screen pixels)
6
15
  export const WAYPOINT_CONFIG = {
7
16
  // Target size in screen pixels (this controls the final sprite size)
@@ -60,9 +69,8 @@ export function createWaypointSprite (options: {
60
69
  visualScale?: number,
61
70
  opacity?: number,
62
71
  }): WaypointSprite {
63
- const color = options.color ?? 0xFF_00_00
72
+ let displayColor = options.color ?? 0xFF_00_00
64
73
  const depthTest = options.depthTest ?? false
65
- const labelYOffset = options.labelYOffset ?? 1.5
66
74
 
67
75
  // Get visual scale from options, metadata, server metadata, or default
68
76
  // Priority: options.visualScale > metadata.visualScale > window.serverMetadata?.waypointVisualScale > DEFAULT
@@ -78,61 +86,87 @@ export function createWaypointSprite (options: {
78
86
  ?? (typeof window === 'undefined' ? undefined : (window as any).serverMetadata?.waypointOpacity)
79
87
  ?? WAYPOINT_CONFIG.DEFAULT_OPACITY
80
88
 
81
- // Build combined sprite
82
- const sprite = createCombinedSprite(color, options.label ?? '', '0m', depthTest, visualScale)
89
+ const labelCanvas = createCanvas(getLabelCanvasSize(), getLabelCanvasSize())
90
+ drawCombinedOntoCanvas(labelCanvas, displayColor, options.label ?? '', '0m', visualScale)
91
+
92
+ const labelTexture = new THREE.CanvasTexture(labelCanvas)
93
+ labelTexture.anisotropy = 1
94
+ labelTexture.magFilter = THREE.LinearFilter
95
+ labelTexture.minFilter = THREE.LinearFilter
96
+ const material = new THREE.SpriteMaterial({
97
+ map: labelTexture,
98
+ transparent: true,
99
+ opacity: 1,
100
+ depthTest,
101
+ depthWrite: false,
102
+ })
103
+ const sprite = new THREE.Sprite(material)
104
+ sprite.position.set(0, 0, 0)
83
105
  sprite.renderOrder = 10
84
106
  sprite.material.opacity = opacity
85
107
  let currentLabel = options.label ?? ''
86
108
 
87
- // Performance optimization: cache distance text to avoid unnecessary updates
88
109
  let lastDistanceText = '0m'
89
- let lastDistance = 0
110
+ let lastDistanceBucket = Number.NaN
90
111
 
91
- // Offscreen arrow (detached by default)
92
112
  let arrowSprite: THREE.Sprite | undefined
113
+ let arrowCanvas: OffscreenCanvas | undefined
114
+ let arrowCtx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | undefined
115
+ let arrowTexture: THREE.CanvasTexture | undefined
93
116
  let arrowParent: THREE.Object3D | null = null
94
117
  let arrowEnabled = WAYPOINT_CONFIG.ARROW.enabledDefault
95
118
 
96
- // Group for easy add/remove
97
119
  const group = new THREE.Group()
98
120
  group.add(sprite)
99
121
 
100
- // Initial position
101
122
  const { x, y, z } = options.position
102
123
  group.position.set(x, y, z)
103
124
 
125
+ function refreshLabelTexture () {
126
+ labelTexture.needsUpdate = true
127
+ }
128
+
129
+ function paintArrowOnCanvas () {
130
+ if (!arrowCanvas || !arrowCtx) return
131
+ const size = arrowCanvas.width
132
+ arrowCtx.clearRect(0, 0, size, size)
133
+ arrowCtx.beginPath()
134
+ arrowCtx.moveTo(size * 0.15, size * 0.5)
135
+ arrowCtx.lineTo(size * 0.85, size * 0.5)
136
+ arrowCtx.lineTo(size * 0.5, size * 0.15)
137
+ arrowCtx.closePath()
138
+ const colorHex = `#${displayColor.toString(16).padStart(6, '0')}`
139
+ arrowCtx.lineWidth = 6
140
+ arrowCtx.strokeStyle = 'black'
141
+ arrowCtx.stroke()
142
+ arrowCtx.fillStyle = colorHex
143
+ arrowCtx.fill()
144
+ if (arrowTexture) arrowTexture.needsUpdate = true
145
+ }
146
+
104
147
  function setColor (newColor: number) {
105
- const canvas = drawCombinedCanvas(newColor, currentLabel, '0m', visualScale)
106
- const texture = new THREE.CanvasTexture(canvas)
107
- const mat = sprite.material
108
- mat.map?.dispose()
109
- mat.map = texture
110
- mat.needsUpdate = true
148
+ displayColor = newColor
149
+ lastDistanceText = '0m'
150
+ lastDistanceBucket = 0
151
+ drawCombinedOntoCanvas(labelCanvas, displayColor, currentLabel, '0m', visualScale)
152
+ refreshLabelTexture()
153
+ if (arrowSprite) paintArrowOnCanvas()
111
154
  }
112
155
 
113
156
  function setLabel (newLabel?: string) {
114
157
  currentLabel = newLabel ?? ''
115
- const canvas = drawCombinedCanvas(color, currentLabel, '0m', visualScale)
116
- const texture = new THREE.CanvasTexture(canvas)
117
- const mat = sprite.material
118
- mat.map?.dispose()
119
- mat.map = texture
120
- mat.needsUpdate = true
158
+ drawCombinedOntoCanvas(labelCanvas, displayColor, currentLabel, lastDistanceText, visualScale)
159
+ refreshLabelTexture()
121
160
  }
122
161
 
123
162
  function updateDistanceText (label: string, distanceText: string) {
124
- // Performance optimization: only update if distance text actually changed
125
163
  if (distanceText === lastDistanceText) {
126
164
  return
127
165
  }
128
166
  lastDistanceText = distanceText
129
167
 
130
- const canvas = drawCombinedCanvas(color, label, distanceText, visualScale)
131
- const texture = new THREE.CanvasTexture(canvas)
132
- const mat = sprite.material
133
- mat.map?.dispose()
134
- mat.map = texture
135
- mat.needsUpdate = true
168
+ drawCombinedOntoCanvas(labelCanvas, displayColor, label, distanceText, visualScale)
169
+ refreshLabelTexture()
136
170
  }
137
171
 
138
172
  function setVisible (visible: boolean) {
@@ -143,7 +177,6 @@ export function createWaypointSprite (options: {
143
177
  group.position.set(nx, ny, nz)
144
178
  }
145
179
 
146
- // Keep constant pixel size on screen using global config
147
180
  function updateScaleScreenPixels (
148
181
  cameraPosition: THREE.Vector3,
149
182
  cameraFov: number,
@@ -152,7 +185,6 @@ export function createWaypointSprite (options: {
152
185
  ) {
153
186
  const vFovRad = cameraFov * Math.PI / 180
154
187
  const worldUnitsPerScreenHeightAtDist = Math.tan(vFovRad / 2) * 2 * distance
155
- // Use configured target screen size with visual scale multiplier
156
188
  const scale = worldUnitsPerScreenHeightAtDist * (WAYPOINT_CONFIG.TARGET_SCREEN_PX * visualScale / viewportHeightPx)
157
189
  sprite.scale.set(scale, scale, 1)
158
190
  }
@@ -160,28 +192,15 @@ export function createWaypointSprite (options: {
160
192
  function ensureArrow () {
161
193
  if (arrowSprite) return
162
194
  const size = 128
163
- const canvas = createCanvas(size, size)
164
- const ctx = canvas.getContext('2d')!
165
- ctx.clearRect(0, 0, size, size)
166
-
167
- // Draw arrow shape
168
- ctx.beginPath()
169
- ctx.moveTo(size * 0.15, size * 0.5)
170
- ctx.lineTo(size * 0.85, size * 0.5)
171
- ctx.lineTo(size * 0.5, size * 0.15)
172
- ctx.closePath()
173
-
174
- // Use waypoint color for arrow
175
- const colorHex = `#${color.toString(16).padStart(6, '0')}`
176
- ctx.lineWidth = 6
177
- ctx.strokeStyle = 'black'
178
- ctx.stroke()
179
- ctx.fillStyle = colorHex
180
- ctx.fill()
181
-
182
- const texture = new THREE.CanvasTexture(canvas)
183
- const material = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false, depthWrite: false, opacity })
184
- arrowSprite = new THREE.Sprite(material)
195
+ arrowCanvas = createCanvas(size, size)
196
+ arrowCtx = arrowCanvas.getContext('2d')!
197
+ paintArrowOnCanvas()
198
+ arrowTexture = new THREE.CanvasTexture(arrowCanvas)
199
+ arrowTexture.anisotropy = 1
200
+ arrowTexture.magFilter = THREE.LinearFilter
201
+ arrowTexture.minFilter = THREE.LinearFilter
202
+ const matTex = new THREE.SpriteMaterial({ map: arrowTexture, transparent: true, depthTest: false, depthWrite: false, opacity })
203
+ arrowSprite = new THREE.Sprite(matTex)
185
204
  arrowSprite.renderOrder = 12
186
205
  arrowSprite.visible = false
187
206
  if (arrowParent) arrowParent.add(arrowSprite)
@@ -306,9 +325,8 @@ export function createWaypointSprite (options: {
306
325
  return false
307
326
  }
308
327
 
309
- function computeDistance (_cameraPosition: THREE.Vector3): number {
310
- // group.position is in scene space; camera is at scene origin (0,0,0)
311
- return group.position.length()
328
+ function computeDistance (cameraPosition: THREE.Vector3): number {
329
+ return cameraPosition.distanceTo(group.position)
312
330
  }
313
331
 
314
332
  function updateForCamera (
@@ -318,17 +336,14 @@ export function createWaypointSprite (options: {
318
336
  viewportHeightPx: number
319
337
  ): boolean {
320
338
  const distance = computeDistance(cameraPosition)
321
- // Keep constant pixel size
322
339
  updateScaleScreenPixels(cameraPosition, camera.fov, distance, viewportHeightPx)
323
340
 
324
- // Performance optimization: only update distance text if distance changed significantly
325
- const roundedDistance = Math.round(distance)
326
- if (Math.abs(roundedDistance - lastDistance) >= 1) {
327
- lastDistance = roundedDistance
328
- updateDistanceText(currentLabel, `${roundedDistance}m`)
341
+ const bucket = Math.round(distance / DISTANCE_LABEL_STEP_M) * DISTANCE_LABEL_STEP_M
342
+ if (bucket !== lastDistanceBucket) {
343
+ lastDistanceBucket = bucket
344
+ updateDistanceText(currentLabel, `${Math.max(0, bucket)}m`)
329
345
  }
330
346
 
331
- // Update arrow and visibility
332
347
  const onScreen = updateOffscreenArrow(camera, viewportWidthPx, viewportHeightPx)
333
348
  setVisible(onScreen)
334
349
  return onScreen
@@ -339,7 +354,6 @@ export function createWaypointSprite (options: {
339
354
  mat.map?.dispose()
340
355
  mat.dispose()
341
356
  if (arrowSprite) {
342
- // Remove arrow from parent before disposing
343
357
  if (arrowSprite.parent) {
344
358
  arrowSprite.parent.remove(arrowSprite)
345
359
  }
@@ -347,6 +361,10 @@ export function createWaypointSprite (options: {
347
361
  am.map?.dispose()
348
362
  am.dispose()
349
363
  }
364
+ arrowSprite = undefined
365
+ arrowCanvas = undefined
366
+ arrowCtx = undefined
367
+ arrowTexture = undefined
350
368
  }
351
369
 
352
370
  return {
@@ -365,41 +383,46 @@ export function createWaypointSprite (options: {
365
383
  }
366
384
 
367
385
  // Internal helpers
368
- function drawCombinedCanvas (color: number, id: string, distance: string, visualScale = 1): OffscreenCanvas {
369
- const scale = WAYPOINT_CONFIG.CANVAS_SCALE * (globalThis.devicePixelRatio || 1)
370
- const size = WAYPOINT_CONFIG.CANVAS_SIZE * scale
371
- const canvas = createCanvas(size, size)
386
+ function computeLabelCanvasLineScale (): number {
387
+ const dpr = globalThis.devicePixelRatio || 1
388
+ const effectiveDpr = Math.min(dpr, LABEL_CANVAS_MAX_DEVICE_PIXEL_RATIO)
389
+ return WAYPOINT_CONFIG.CANVAS_SCALE * effectiveDpr
390
+ }
391
+
392
+ function getLabelCanvasSize (): number {
393
+ return Math.round(WAYPOINT_CONFIG.CANVAS_SIZE * computeLabelCanvasLineScale())
394
+ }
395
+
396
+ function drawCombinedOntoCanvas (
397
+ canvas: OffscreenCanvas,
398
+ color: number,
399
+ id: string,
400
+ distance: string,
401
+ visualScale: number
402
+ ): void {
403
+ const size = canvas.width
404
+ const scale = computeLabelCanvasLineScale()
372
405
  const ctx = canvas.getContext('2d')!
373
406
 
374
- // Clear canvas
375
407
  ctx.clearRect(0, 0, size, size)
376
408
 
377
- // Draw dot with visual scale applied
378
409
  const centerX = size / 2
379
410
  const dotY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DOT_Y)
380
- const radius = Math.round(size * 0.05 * visualScale) // Dot takes up ~5% of canvas height, scaled
381
- const borderWidth = Math.max(2, Math.round(4 * scale * visualScale))
411
+ const innerRadius = Math.round(size * 0.05 * visualScale)
412
+ const outlinePad = Math.max(2, Math.round(4 * scale * visualScale))
413
+ const dotRadius = innerRadius + outlinePad
382
414
 
383
- // Outer border (black)
384
415
  ctx.beginPath()
385
- ctx.arc(centerX, dotY, radius + borderWidth, 0, Math.PI * 2)
386
- ctx.fillStyle = 'black'
387
- ctx.fill()
388
-
389
- // Inner circle (colored)
390
- ctx.beginPath()
391
- ctx.arc(centerX, dotY, radius, 0, Math.PI * 2)
416
+ ctx.arc(centerX, dotY, dotRadius, 0, Math.PI * 2)
392
417
  ctx.fillStyle = `#${color.toString(16).padStart(6, '0')}`
393
418
  ctx.fill()
394
419
 
395
- // Text properties
396
420
  ctx.textAlign = 'center'
397
421
  ctx.textBaseline = 'middle'
398
422
 
399
- // Title with visual scale applied
400
- const nameFontPx = Math.round(size * 0.08 * visualScale) // ~8% of canvas height, scaled
401
- const distanceFontPx = Math.round(size * 0.06 * visualScale) // ~6% of canvas height, scaled
402
- ctx.font = `bold ${nameFontPx}px mojangles`
423
+ const nameFontPx = Math.round(size * 0.08 * visualScale)
424
+ const distanceFontPx = Math.round(size * 0.06 * visualScale)
425
+ ctx.font = `800 ${nameFontPx}px mojangles`
403
426
  ctx.lineWidth = Math.max(2, Math.round(3 * scale * visualScale))
404
427
  const nameY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.NAME_Y)
405
428
 
@@ -408,8 +431,7 @@ function drawCombinedCanvas (color: number, id: string, distance: string, visual
408
431
  ctx.fillStyle = 'white'
409
432
  ctx.fillText(id, centerX, nameY)
410
433
 
411
- // Distance with visual scale applied
412
- ctx.font = `bold ${distanceFontPx}px mojangles`
434
+ ctx.font = `800 ${distanceFontPx}px mojangles`
413
435
  ctx.lineWidth = Math.max(2, Math.round(2 * scale * visualScale))
414
436
  const distanceY = Math.round(size * WAYPOINT_CONFIG.LAYOUT.DISTANCE_Y)
415
437
 
@@ -417,26 +439,6 @@ function drawCombinedCanvas (color: number, id: string, distance: string, visual
417
439
  ctx.strokeText(distance, centerX, distanceY)
418
440
  ctx.fillStyle = '#CCCCCC'
419
441
  ctx.fillText(distance, centerX, distanceY)
420
-
421
- return canvas
422
- }
423
-
424
- function createCombinedSprite (color: number, id: string, distance: string, depthTest: boolean, visualScale = 1): THREE.Sprite {
425
- const canvas = drawCombinedCanvas(color, id, distance, visualScale)
426
- const texture = new THREE.CanvasTexture(canvas)
427
- texture.anisotropy = 1
428
- texture.magFilter = THREE.LinearFilter
429
- texture.minFilter = THREE.LinearFilter
430
- const material = new THREE.SpriteMaterial({
431
- map: texture,
432
- transparent: true,
433
- opacity: 1,
434
- depthTest,
435
- depthWrite: false,
436
- })
437
- const sprite = new THREE.Sprite(material)
438
- sprite.position.set(0, 0, 0)
439
- return sprite
440
442
  }
441
443
 
442
444
  export const WaypointHelpers = {