minecraft-renderer 0.1.95 → 0.1.97

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.95",
3
+ "version": "0.1.97",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,6 +1,8 @@
1
1
  //@ts-nocheck
2
2
  /** Shared geometry export shapes (worker bridge + main-thread viewer). */
3
3
 
4
+ import { SHADER_CUBES_FORMAT_VERSION } from './shaderCubeFormat'
5
+
4
6
  export interface ExportedSection {
5
7
  key: string
6
8
  position: { x: number; y: number; z: number }
@@ -25,7 +27,7 @@ export interface ExportedSection {
25
27
  shaderCubes?: {
26
28
  words: Uint32Array
27
29
  count: number
28
- formatVersion: 3
30
+ formatVersion: typeof SHADER_CUBES_FORMAT_VERSION
29
31
  }
30
32
  visibilitySet?: number
31
33
  }
@@ -0,0 +1,3 @@
1
+ //@ts-nocheck
2
+ export const SHADER_CUBES_FORMAT_VERSION = 4 as const
3
+ export const SHADER_CUBES_WORDS_PER_FACE = 4 as const
@@ -1,5 +1,6 @@
1
1
  //@ts-nocheck
2
2
  import { BlockType } from '../playground/shared'
3
+ import { SHADER_CUBES_FORMAT_VERSION } from './shaderCubeFormat'
3
4
 
4
5
  export const SECTION_HEIGHT = 16
5
6
 
@@ -80,7 +81,7 @@ export type MesherGeometryOutput = {
80
81
  shaderCubes?: {
81
82
  words: Uint32Array
82
83
  count: number
83
- formatVersion: 3
84
+ formatVersion: typeof SHADER_CUBES_FORMAT_VERSION
84
85
  }
85
86
  /** Packed VisibilitySet (36 bits) for section occlusion / smart cull. */
86
87
  visibilitySet?: number
@@ -145,41 +145,116 @@ function shortestYawRadians(fromYawRad: number, toYawRad: number): number {
145
145
  return norm > Math.PI ? norm - TAU_YAW : norm
146
146
  }
147
147
 
148
+ /** Voice chat presence for a player, drawn as a small mic glyph before their nametag. */
149
+ export type PlayerVoiceState = 'speaking' | 'muted' | 'idle'
150
+
151
+ /**
152
+ * Draws a small mic glyph centered at the origin of the current transform.
153
+ * `size` is the glyph's bounding box (both width and height).
154
+ */
155
+ function drawVoiceStateIcon(ctx: OffscreenCanvasRenderingContext2D, state: PlayerVoiceState, cx: number, cy: number, size: number) {
156
+ const color = state === 'speaking' ? '#3ddc47' : state === 'muted' ? '#ff5555' : '#cfcfcf'
157
+
158
+ ctx.save()
159
+ ctx.translate(cx, cy)
160
+
161
+ if (state === 'speaking') {
162
+ ctx.beginPath()
163
+ ctx.arc(0, 0, size * 0.62, 0, Math.PI * 2)
164
+ ctx.strokeStyle = 'rgba(61, 220, 71, 0.55)'
165
+ ctx.lineWidth = Math.max(1, size * 0.08)
166
+ ctx.stroke()
167
+ }
168
+
169
+ ctx.fillStyle = color
170
+ ctx.strokeStyle = color
171
+ ctx.lineWidth = Math.max(1, size * 0.12)
172
+
173
+ // Mic body: a rounded capsule
174
+ const bodyWidth = size * 0.34
175
+ const bodyHeight = size * 0.55
176
+ const bodyRadius = bodyWidth / 2
177
+ ctx.beginPath()
178
+ ctx.moveTo(-bodyRadius, -bodyHeight / 2 + bodyRadius)
179
+ ctx.arcTo(-bodyRadius, -bodyHeight / 2, 0, -bodyHeight / 2, bodyRadius)
180
+ ctx.arcTo(bodyRadius, -bodyHeight / 2, bodyRadius, -bodyHeight / 2 + bodyRadius, bodyRadius)
181
+ ctx.lineTo(bodyRadius, bodyHeight / 2 - bodyRadius)
182
+ ctx.arcTo(bodyRadius, bodyHeight / 2, 0, bodyHeight / 2, bodyRadius)
183
+ ctx.arcTo(-bodyRadius, bodyHeight / 2, -bodyRadius, bodyHeight / 2 - bodyRadius, bodyRadius)
184
+ ctx.closePath()
185
+ ctx.fill()
186
+
187
+ // Stand: arc + base line
188
+ ctx.beginPath()
189
+ ctx.arc(0, bodyHeight / 2 - bodyRadius / 2, size * 0.32, Math.PI * 0.15, Math.PI * 0.85, false)
190
+ ctx.stroke()
191
+ ctx.beginPath()
192
+ ctx.moveTo(0, size * 0.5)
193
+ ctx.lineTo(0, size * 0.62)
194
+ ctx.stroke()
195
+
196
+ if (state === 'muted') {
197
+ ctx.beginPath()
198
+ ctx.moveTo(-size * 0.42, -size * 0.42)
199
+ ctx.lineTo(size * 0.42, size * 0.42)
200
+ ctx.stroke()
201
+ }
202
+
203
+ ctx.restore()
204
+ }
205
+
148
206
  function getUsernameTexture(
149
- { username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255 }: any,
207
+ { username, nameTagBackgroundColor = 'rgba(0, 0, 0, 0.3)', nameTagTextOpacity = 255, voiceState }: any,
150
208
  { fontFamily = 'mojangles' }: any,
151
209
  version: string
152
210
  ) {
153
- const canvas = createCanvas(64, 64)
154
-
211
+ const measureCanvas = createCanvas(64, 64)
155
212
  const PrismarineChat = PrismarineChatLoader(version)
156
213
 
157
- const ctx = canvas.getContext('2d')
158
- if (!ctx) throw new Error('Could not get 2d context')
214
+ const measureCtx = measureCanvas.getContext('2d')
215
+ if (!measureCtx) throw new Error('Could not get 2d context')
159
216
 
160
217
  const fontSize = 48
161
218
  const padding = 5
162
- ctx.font = `${fontSize}px ${fontFamily}`
219
+ measureCtx.font = `${fontSize}px ${fontFamily}`
163
220
 
164
221
  const plainLines = String(typeof username === 'string' ? username : new PrismarineChat(username).toString()).split('\n')
165
222
  let textWidth = 0
166
223
  for (const line of plainLines) {
167
- const width = ctx.measureText(line).width + padding * 2
224
+ const width = measureCtx.measureText(line).width + padding * 2
168
225
  if (width > textWidth) textWidth = width
169
226
  }
170
227
 
171
- canvas.width = textWidth
172
- canvas.height = (fontSize + padding) * plainLines.length
228
+ const height = (fontSize + padding) * plainLines.length
173
229
 
174
- ctx.fillStyle = nameTagBackgroundColor
175
- ctx.fillRect(0, 0, canvas.width, canvas.height)
230
+ // Render the text on its own canvas first so the existing centered-text
231
+ // layout in renderComponent() is unaffected by the icon we add alongside it.
232
+ const textCanvas = createCanvas(textWidth, height)
233
+ const textCtx = textCanvas.getContext('2d')
234
+ if (!textCtx) throw new Error('Could not get 2d context')
176
235
 
177
- ctx.globalAlpha = nameTagTextOpacity / 255
236
+ textCtx.fillStyle = nameTagBackgroundColor
237
+ textCtx.fillRect(0, 0, textCanvas.width, textCanvas.height)
238
+ textCtx.globalAlpha = nameTagTextOpacity / 255
178
239
 
179
- const textRendered = renderComponent(username, PrismarineChat, canvas, fontSize, 'white', -padding + fontSize)
240
+ const textRendered = renderComponent(username, PrismarineChat, textCanvas, fontSize, 'white', -padding + fontSize)
180
241
  if (!textRendered) return undefined
181
242
 
182
- ctx.globalAlpha = 1
243
+ textCtx.globalAlpha = 1
244
+
245
+ if (!voiceState) return textCanvas
246
+
247
+ const iconSize = fontSize * 0.55
248
+ const iconAreaWidth = iconSize + padding * 2
249
+
250
+ const canvas = createCanvas(iconAreaWidth + textWidth, height)
251
+ const ctx = canvas.getContext('2d')
252
+ if (!ctx) throw new Error('Could not get 2d context')
253
+
254
+ ctx.fillStyle = nameTagBackgroundColor
255
+ ctx.fillRect(0, 0, canvas.width, canvas.height)
256
+ drawVoiceStateIcon(ctx, voiceState, iconAreaWidth / 2, canvas.height / 2, iconSize)
257
+ ctx.drawImage(textCanvas, iconAreaWidth, 0)
183
258
 
184
259
  return canvas
185
260
  }
@@ -234,6 +309,13 @@ const addNametag = (entity, options: { fontFamily: string }, mesh, version: stri
234
309
  return nameTag
235
310
  }
236
311
 
312
+ /** Player nametags are placed above the skin model and scaled up; text_display/mob nametags are not. */
313
+ function applyPlayerNametagTransform(nametag: THREE.Object3D | undefined, playerObject: { position: THREE.Vector3; scale: THREE.Vector3 }) {
314
+ if (!nametag) return
315
+ nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
316
+ nametag.scale.multiplyScalar(12)
317
+ }
318
+
237
319
  // todo cleanup
238
320
  const nametags = {}
239
321
 
@@ -395,6 +477,7 @@ export class Entities {
395
477
  }
396
478
  this.entities = {}
397
479
  this.currentSkinUrls = {}
480
+ this.playerVoiceStates = {}
398
481
 
399
482
  // Clean up player entity
400
483
  if (this.playerEntity) {
@@ -447,9 +530,7 @@ export class Entities {
447
530
  }
448
531
 
449
532
  private setSceneAttached(attached: boolean, entity: THREE.Object3D | null = null): void {
450
- const targets = entity
451
- ? [entity]
452
- : [...Object.values(this.entities), ...(this.playerEntity ? [this.playerEntity] : [])]
533
+ const targets = entity ? [entity] : [...Object.values(this.entities), ...(this.playerEntity ? [this.playerEntity] : [])]
453
534
  for (const ent of targets) {
454
535
  if (attached) {
455
536
  if (!this.worldRenderer.scene.children.includes(ent)) this.worldRenderer.scene.add(ent)
@@ -739,6 +820,33 @@ export class Entities {
739
820
  return playerObject
740
821
  }
741
822
 
823
+ /** Current voice chat presence per entity id, keyed as a string. Absent = no voice indicator. */
824
+ playerVoiceStates: Record<string, PlayerVoiceState> = {}
825
+
826
+ /**
827
+ * Show or hide the mic glyph on a player's nametag. `state` undefined hides it
828
+ * (e.g. the player left voice chat, or walked out of proximity range).
829
+ * Regenerates the nametag texture, so callers should only invoke this on
830
+ * actual state transitions (speaking start/stop, mute toggle), not per-frame.
831
+ */
832
+ setPlayerVoiceState(entityId: string | number, username: string, state: PlayerVoiceState | undefined) {
833
+ const key = String(entityId)
834
+ if (state) {
835
+ this.playerVoiceStates[key] = state
836
+ } else {
837
+ delete this.playerVoiceStates[key]
838
+ }
839
+
840
+ const e = this.entities[entityId]
841
+ const playerObject = e?.playerObject
842
+ if (!e || !playerObject) return
843
+ const mesh = e.children.find(c => c.name === 'mesh')
844
+ if (!mesh) return
845
+
846
+ const nametag = addNametag({ username, voiceState: state }, this.entitiesOptions, mesh, this.worldRenderer.version)
847
+ applyPlayerNametagTransform(nametag, playerObject)
848
+ }
849
+
742
850
  uuidPerSkinUrlsCache = {} as Record<string, { skinUrl?: string; capeUrl?: string }>
743
851
  currentSkinUrls = {} as Record<string, string>
744
852
 
@@ -1089,6 +1197,7 @@ export class Entities {
1089
1197
  disposeObject(e)
1090
1198
  // todo dispose textures as well ?
1091
1199
  delete this.entities[entity.id]
1200
+ delete this.playerVoiceStates[String(entity.id)]
1092
1201
  return
1093
1202
  }
1094
1203
  if (!this.isRenderingAllowed()) return
@@ -1160,11 +1269,13 @@ export class Entities {
1160
1269
  mesh = wrapper
1161
1270
 
1162
1271
  if (entity.username) {
1163
- const nametag = addNametag(entity, { fontFamily: 'mojangles' }, wrapper, this.worldRenderer.version)
1164
- if (nametag) {
1165
- nametag.position.y = playerObject.position.y + playerObject.scale.y * 16 + 3
1166
- nametag.scale.multiplyScalar(12)
1167
- }
1272
+ const nametag = addNametag(
1273
+ { ...entity, voiceState: this.playerVoiceStates[entity.id] },
1274
+ { fontFamily: 'mojangles' },
1275
+ wrapper,
1276
+ this.worldRenderer.version
1277
+ )
1278
+ applyPlayerNametagTransform(nametag, playerObject)
1168
1279
  }
1169
1280
  } else {
1170
1281
  mesh = getEntityMesh(this.mcData, entity, this.worldRenderer, this.entitiesOptions, { ...overrides, customModel: entity['customModel'] })
@@ -35,6 +35,7 @@ export const getBackendMethods = (worldRenderer: WorldRendererThree): any => {
35
35
  playEntityAnimation: worldRenderer.entities.playAnimation.bind(worldRenderer.entities),
36
36
  damageEntity: worldRenderer.entities.handleDamageEvent.bind(worldRenderer.entities),
37
37
  updatePlayerSkin: worldRenderer.entities.updatePlayerSkin.bind(worldRenderer.entities),
38
+ setPlayerVoiceState: worldRenderer.entities.setPlayerVoiceState.bind(worldRenderer.entities),
38
39
  applyTemporaryPlayerSkinOverride: worldRenderer.entities.applyTemporaryPlayerSkinOverride.bind(worldRenderer.entities),
39
40
  changeHandSwingingState: worldRenderer.changeHandSwingingState.bind(worldRenderer),
40
41
  getHighestBlocks: worldRenderer.getHighestBlocks.bind(worldRenderer),
@@ -7,6 +7,64 @@ export type { BlockLightmapParams }
7
7
  // Face order: UP=0, DOWN=1, EAST=2, WEST=3, SOUTH=4, NORTH=5
8
8
  // matches WASM mesher face order (mesher.rs FACE_NAMES)
9
9
 
10
+ /** Term of tile-local UV in quad-corner coordinates (u, v ∈ {0,1}). */
11
+ type FaceUvTerm = 'u' | '1-u' | 'v' | '1-v'
12
+
13
+ /**
14
+ * Canonical tile-local UV per face for su>0, sv>0. Matches legacy elemFaces
15
+ * (+ implicit +180° on down; see render-from-wasm.ts `r += 180`).
16
+ * Index = faceId: UP, DOWN, EAST, WEST, SOUTH, NORTH.
17
+ * Single source of truth — GLSL and shaderCubeFaceUv() are generated from this.
18
+ */
19
+ export const FACE_UV_TERMS: ReadonlyArray<readonly [FaceUvTerm, FaceUvTerm]> = [
20
+ ['u', '1-v'], // UP
21
+ ['1-u', 'v'], // DOWN
22
+ ['v', 'u'], // EAST
23
+ ['v', 'u'], // WEST
24
+ ['u', 'v'], // SOUTH
25
+ ['1-u', 'v'] // NORTH
26
+ ]
27
+
28
+ const glslTerm = (t: FaceUvTerm) =>
29
+ ({
30
+ u: 'u',
31
+ v: 'v',
32
+ '1-u': '1.0 - u',
33
+ '1-v': '1.0 - v'
34
+ })[t]
35
+
36
+ /** GLSL if/else-if chain for per-face UV — must stay in sync with FACE_UV_TERMS. */
37
+ export function buildFaceUvGlsl(): string {
38
+ return FACE_UV_TERMS.map(([ut, vt], i) => ` ${i === 0 ? 'if' : 'else if'} (faceId == ${i}u) uv = vec2(${glslTerm(ut)}, ${glslTerm(vt)});`).join('\n')
39
+ }
40
+
41
+ function evalFaceUvTerm(t: FaceUvTerm, cu: number, cv: number): number {
42
+ switch (t) {
43
+ case 'u':
44
+ return cu
45
+ case '1-u':
46
+ return 1 - cu
47
+ case 'v':
48
+ return cv
49
+ case '1-v':
50
+ return 1 - cv
51
+ }
52
+ }
53
+
54
+ /** vi — quad corner in shader order (0..3): u = vi & 1, v = (vi >> 1) & 1. */
55
+ export function shaderCubeFaceUv(faceId: number, vi: number, flipU: boolean, flipV: boolean): [number, number] {
56
+ const [ut, vt] = FACE_UV_TERMS[faceId] ?? FACE_UV_TERMS[0]
57
+ const cu = vi & 1
58
+ const cv = (vi >> 1) & 1
59
+ let uLocal = evalFaceUvTerm(ut, cu, cv)
60
+ let vLocal = evalFaceUvTerm(vt, cu, cv)
61
+ if (flipU) uLocal = 1 - uLocal
62
+ if (flipV) vLocal = 1 - vLocal
63
+ return [uLocal, vLocal]
64
+ }
65
+
66
+ const faceUvGlsl = buildFaceUvGlsl()
67
+
10
68
  const vertexShader = /* glsl */ `
11
69
  precision highp float;
12
70
  precision highp int;
@@ -143,18 +201,16 @@ void main() {
143
201
  v_tintIndex = int(tint);
144
202
  v_faceId = int(faceId);
145
203
 
146
- // --- Per-face UV transform (legacy elemFaces + down +180°) ---
147
- if (faceId == 0u) {
148
- v_uv = vec2(u, 1.0 - v);
149
- } else if (faceId == 1u) {
150
- v_uv = vec2(1.0 - u, 1.0 - v);
151
- } else if (faceId == 2u || faceId == 3u) {
152
- v_uv = vec2(v, u);
153
- } else if (faceId == 4u) {
154
- v_uv = vec2(u, v);
155
- } else { // faceId == 5u
156
- v_uv = vec2(1.0 - u, v);
157
- }
204
+ // --- Per-face UV: canonical tile-local UV (su>0, sv>0), generated from
205
+ // FACE_UV_TERMS — same source as shaderCubeFaceUv() ---
206
+ vec2 uv;
207
+ ${faceUvGlsl}
208
+
209
+ // su/sv sign from resolved model: negative scale = mirror on axis.
210
+ // Bit offsets mirror WORD2.FLIP_U_SHIFT / WORD0.FLIP_V_SHIFT (GLSL cannot import TS).
211
+ if (((a_w2 >> 31u) & 0x1u) != 0u) uv.x = 1.0 - uv.x;
212
+ if (((a_w0 >> 31u) & 0x1u) != 0u) uv.y = 1.0 - uv.y;
213
+ v_uv = uv;
158
214
 
159
215
  // --- Position: section base (multiples of 16) + face quad + block-local 0..15 ---
160
216
  // Must mirror WORD2/WORD3 constants in TS (GLSL cannot import them).
@@ -321,6 +377,9 @@ void main() {
321
377
  }
322
378
  `
323
379
 
380
+ /** Vertex shader source (includes generated face UV table). Exported for parity tests. */
381
+ export const cubeBlockVertexShader = vertexShader
382
+
324
383
  export function createCubeBlockMaterial(): THREE.ShaderMaterial {
325
384
  return new THREE.ShaderMaterial({
326
385
  vertexShader,
@@ -417,7 +476,8 @@ export const WORD0 = {
417
476
  FACE_SHIFT: 12,
418
477
  TINT_SHIFT: 15,
419
478
  AO_SHIFT: 23,
420
- TRANSPARENT_SHIFT: 31
479
+ /** sv<0 mirror flag — no free bits remain in word0 after this. */
480
+ FLIP_V_SHIFT: 31
421
481
  } as const
422
482
 
423
483
  export const WORD1 = {
@@ -434,7 +494,8 @@ export const WORD2 = {
434
494
  SECTION_X_HI_SHIFT: 19,
435
495
  SECTION_Z_HI_SHIFT: 25,
436
496
  SECTION_HI_BITS: 6,
437
- SPARE_BITS: 1
497
+ /** su<0 mirror flag — no free bits remain in word2 after this. */
498
+ FLIP_U_SHIFT: 31
438
499
  } as const
439
500
 
440
501
  /** Section base X/Z: low 16 bits in a_w3, high 6 in a_w2 (22-bit biased section index). */
@@ -5,6 +5,7 @@ import type { ExportedSection, ExportedWorldGeometry } from '../mesher-shared/ex
5
5
  import { calculateSkyLightSimple } from '../lib/skyLight'
6
6
  import { bakeLegacyVertexColors } from '../lib/bakeLegacyLight'
7
7
  import { getShaderCubeResources } from '../wasm-mesher/bridge/shaderCubeBridge'
8
+ import { SHADER_CUBES_FORMAT_VERSION } from '../mesher-shared/shaderCubeFormat'
8
9
  import { createCubeBlockMaterial, setCubeSkyLevel } from './shaders/cubeBlockShader'
9
10
  import { createShaderCubeMesh } from './shaderCubeMesh'
10
11
 
@@ -181,6 +182,8 @@ export function createMeshesFromExport(
181
182
  ): THREE.Group[] {
182
183
  const groups: THREE.Group[] = []
183
184
  const resolvedShaderMat = shaderMaterial ?? shaderMaterialForExport(material, skyLevel)
185
+ let skippedShaderCubeSections = 0
186
+ let skippedShaderCubeFormatVersion: number | undefined
184
187
 
185
188
  for (const section of exportData.sections) {
186
189
  const group = new THREE.Group()
@@ -213,9 +216,14 @@ export function createMeshesFromExport(
213
216
 
214
217
  const shaderCubes = section.shaderCubes
215
218
  if (shaderCubes && shaderCubes.count > 0 && resolvedShaderMat) {
216
- const shaderMesh = createShaderCubeMesh(shaderCubes, resolvedShaderMat)
217
- shaderMesh.position.set(section.position.x, section.position.y, section.position.z)
218
- group.add(shaderMesh)
219
+ if (shaderCubes.formatVersion !== SHADER_CUBES_FORMAT_VERSION) {
220
+ skippedShaderCubeSections++
221
+ skippedShaderCubeFormatVersion ??= shaderCubes.formatVersion
222
+ } else {
223
+ const shaderMesh = createShaderCubeMesh(shaderCubes, resolvedShaderMat)
224
+ shaderMesh.position.set(section.position.x, section.position.y, section.position.z)
225
+ group.add(shaderMesh)
226
+ }
219
227
  }
220
228
 
221
229
  if (group.children.length > 0) {
@@ -223,6 +231,13 @@ export function createMeshesFromExport(
223
231
  }
224
232
  }
225
233
 
234
+ if (skippedShaderCubeSections > 0) {
235
+ console.warn(
236
+ `[worldGeometryExport] Skipped ${skippedShaderCubeSections} shader-cube section(s): ` +
237
+ `export format ${skippedShaderCubeFormatVersion} ≠ current ${SHADER_CUBES_FORMAT_VERSION}`
238
+ )
239
+ }
240
+
226
241
  return groups
227
242
  }
228
243
 
@@ -6,12 +6,12 @@
6
6
  */
7
7
 
8
8
  import blocksAtlasesJson from 'mc-assets/dist/blocksAtlases.json'
9
+ import { SHADER_CUBES_FORMAT_VERSION, SHADER_CUBES_WORDS_PER_FACE } from '../../mesher-shared/shaderCubeFormat'
9
10
  import { WORD0, WORD1, WORD2, WORD3 } from '../../three/shaders/cubeBlockShader'
10
11
  import { TextureIndexMapping, type TextureEntry } from '../../three/shaders/textureIndexMapping'
11
12
  import { TintPalette } from '../../three/shaders/tintPalette'
12
13
 
13
- export const SHADER_CUBES_FORMAT_VERSION = 3 as const
14
- export const SHADER_CUBES_WORDS_PER_FACE = 4 as const
14
+ export { SHADER_CUBES_FORMAT_VERSION, SHADER_CUBES_WORDS_PER_FACE } from '../../mesher-shared/shaderCubeFormat'
15
15
 
16
16
  export type ShaderCubesOutput = {
17
17
  words: Uint32Array
@@ -83,32 +83,78 @@ let tintPalette: TintPalette | null = null
83
83
  let textureIndexMapping: TextureIndexMapping | null = null
84
84
  let tintsMissingWarned = false
85
85
 
86
- /** Convert mc-assets texture scales (normalized or negative) to pixel tile size for index lookup. */
87
- function normalizeTextureEntryForTileIndex(tex: { u?: number; v?: number; su?: number; sv?: number }, atlasWidth: number, tileSize: number): TextureEntry {
86
+ type FaceTextureRef = TextureEntry & { tileIndex?: number }
87
+
88
+ export type ResolvedFaceUv = { tileIndex: number; flipU: boolean; flipV: boolean }
89
+
90
+ const UV_RESOLVE_EPS = 1e-6
91
+
92
+ function isNearInteger(value: number): boolean {
93
+ return Math.abs(value - Math.round(value)) <= UV_RESOLVE_EPS
94
+ }
95
+
96
+ /**
97
+ * Resolve a face to (atlas tile + mirror flags).
98
+ * null ⇒ face does not fit exactly one aligned tile; block must use legacy path.
99
+ */
100
+ export function resolveFaceUv(tex: FaceTextureRef, texMapping: TextureIndexMapping): ResolvedFaceUv | null {
101
+ const tileSize = 16
88
102
  let u = tex.u ?? 0
89
103
  let v = tex.v ?? 0
90
104
  let su = tex.su ?? tileSize
91
105
  let sv = tex.sv ?? tileSize
92
- if (u > 0 && u <= 1) u = Math.round(u * atlasWidth)
93
- if (v > 0 && v <= 1) v = Math.round(v * atlasWidth)
94
- if (Math.abs(su) > 0 && Math.abs(su) <= 1) su = Math.round(Math.abs(su) * atlasWidth) || tileSize
95
- else if (su < 0) su = tileSize
96
- if (Math.abs(sv) > 0 && Math.abs(sv) <= 1) sv = Math.round(Math.abs(sv) * atlasWidth) || tileSize
97
- else if (sv < 0) sv = tileSize
98
- return { u, v, su, sv }
99
- }
100
106
 
101
- type FaceTextureRef = TextureEntry & { tileIndex?: number }
107
+ if (!Number.isFinite(u) || !Number.isFinite(v) || !Number.isFinite(su) || !Number.isFinite(sv)) {
108
+ return null
109
+ }
102
110
 
103
- /** Prefer atlas `tileIndex` from block model (legacy path uses the same). */
104
- export function resolveFaceTileIndex(tex: FaceTextureRef, texMapping: TextureIndexMapping): number {
105
- const fromAtlas = tex.tileIndex
106
- // tile 0 is a special/atlas-padding slot; use pixel fallback for block faces
107
- if (typeof fromAtlas === 'number' && fromAtlas > 0 && fromAtlas < 4096) {
108
- return fromAtlas
111
+ const atlasWidth = texMapping.getTilesPerRow() * tileSize
112
+ if (Math.abs(su) <= 1) {
113
+ u *= atlasWidth
114
+ v *= atlasWidth
115
+ su *= atlasWidth
116
+ sv *= atlasWidth
109
117
  }
110
- const entry = normalizeTextureEntryForTileIndex(tex, texMapping.getTilesPerRow() * 16, 16)
111
- return texMapping.tileIndexFromTextureEntry(entry)
118
+
119
+ if (!isNearInteger(u) || !isNearInteger(v) || !isNearInteger(su) || !isNearInteger(sv)) {
120
+ return null
121
+ }
122
+
123
+ const uPx = Math.round(u)
124
+ const vPx = Math.round(v)
125
+ const suPx = Math.round(su)
126
+ const svPx = Math.round(sv)
127
+
128
+ const flipU = suPx < 0
129
+ const flipV = svPx < 0
130
+
131
+ if (Math.abs(suPx) !== tileSize || Math.abs(svPx) !== tileSize) {
132
+ return null
133
+ }
134
+
135
+ const uMin = flipU ? uPx + suPx : uPx
136
+ const vMin = flipV ? vPx + svPx : vPx
137
+
138
+ if (uMin % tileSize !== 0 || vMin % tileSize !== 0) {
139
+ return null
140
+ }
141
+ if (uMin < 0 || vMin < 0) {
142
+ return null
143
+ }
144
+
145
+ const derived = texMapping.tileIndexFromPixelCoords(uMin, vMin)
146
+ if (derived < 0) {
147
+ return null
148
+ }
149
+
150
+ if (typeof tex.tileIndex === 'number') {
151
+ if (tex.tileIndex !== derived) {
152
+ return null
153
+ }
154
+ return { tileIndex: derived, flipU, flipV }
155
+ }
156
+
157
+ return { tileIndex: derived, flipU, flipV }
112
158
  }
113
159
 
114
160
  /** Main thread + worker: use `loadedData` set by the app / mesher (see mesherWasm). */
@@ -158,8 +204,8 @@ export function resetShaderCubeResources(): void {
158
204
 
159
205
  /**
160
206
  * Returns true when the block is a plain 1×1×1 cube that the instanced shader path
161
- * can render exactly like the legacy mesher (no model rotation, single un-rotated
162
- * element with all 6 cardinal faces present, atlas matches shader gate).
207
+ * can render with pixel-parity UV (no model rotation, single un-rotated element with
208
+ * all 6 cardinal faces present, each face exactly one aligned atlas tile).
163
209
  * Pass the already-resolved model variant (`modelVars[variantIndex][0]`).
164
210
  */
165
211
  export function isShaderCubeBlock(
@@ -191,7 +237,7 @@ export function isShaderCubeBlock(
191
237
  if ((eFace as { rotation?: number }).rotation) return false
192
238
  const tex = eFace.texture
193
239
  if (!tex) return false
194
- if (resolveFaceTileIndex(tex as FaceTextureRef, texMapping) < 0) {
240
+ if (resolveFaceUv(tex as FaceTextureRef, texMapping) === null) {
195
241
  return false
196
242
  }
197
243
  }
@@ -199,7 +245,7 @@ export function isShaderCubeBlock(
199
245
  return true
200
246
  }
201
247
 
202
- function packWord0(lx: number, ly: number, lz: number, faceId: number, tintIndex: number, ao: number[]): number {
248
+ function packWord0(lx: number, ly: number, lz: number, faceId: number, tintIndex: number, ao: number[], flipV: boolean): number {
203
249
  let w = 0
204
250
  w |= (lx & 0xf) << WORD0.LX_SHIFT
205
251
  w |= (ly & 0xf) << WORD0.LY_SHIFT
@@ -209,6 +255,9 @@ function packWord0(lx: number, ly: number, lz: number, faceId: number, tintIndex
209
255
  for (let i = 0; i < WORD0.NUM_CORNERS; i++) {
210
256
  w |= ((ao[i] ?? 3) & 3) << (WORD0.AO_SHIFT + i * WORD0.AO_BITS_PER_CORNER)
211
257
  }
258
+ if (flipV) {
259
+ w |= 1 << WORD0.FLIP_V_SHIFT
260
+ }
212
261
  return w >>> 0
213
262
  }
214
263
 
@@ -224,7 +273,7 @@ function biasedSectionIndex(sectionBaseCoord: number): number {
224
273
  return (Math.floor(sectionBaseCoord / 16) + WORD3.SECTION_BIAS) & WORD3.SECTION_MASK
225
274
  }
226
275
 
227
- export function packWord2(texIndex: number, aoDiagonalFlip: boolean, sectionBaseX: number, sectionBaseY: number, sectionBaseZ: number): number {
276
+ export function packWord2(texIndex: number, aoDiagonalFlip: boolean, sectionBaseX: number, sectionBaseY: number, sectionBaseZ: number, flipU = false): number {
228
277
  let w = texIndex & ((1 << WORD2.TEX_INDEX_BITS) - 1)
229
278
  if (aoDiagonalFlip) {
230
279
  w |= 1 << WORD2.DIAGONAL_FLAG_SHIFT
@@ -235,6 +284,9 @@ export function packWord2(texIndex: number, aoDiagonalFlip: boolean, sectionBase
235
284
  const sz = biasedSectionIndex(sectionBaseZ)
236
285
  w |= ((sx >>> 16) & 0x3f) << WORD2.SECTION_X_HI_SHIFT
237
286
  w |= ((sz >>> 16) & 0x3f) << WORD2.SECTION_Z_HI_SHIFT
287
+ if (flipU) {
288
+ w |= 1 << WORD2.FLIP_U_SHIFT
289
+ }
238
290
  return w >>> 0
239
291
  }
240
292
 
@@ -346,11 +398,12 @@ export function tryBuildShaderCubeInstances(
346
398
 
347
399
  const eFace = faces[faceName]
348
400
  const tex = eFace.texture! as FaceTextureRef
349
- const texIndex = resolveFaceTileIndex(tex, textureIndexMapping)
350
- if (texIndex < 0) {
401
+ const resolved = resolveFaceUv(tex, textureIndexMapping)
402
+ if (resolved === null) {
351
403
  words.length = wordsStart
352
404
  return false
353
405
  }
406
+ const { tileIndex: texIndex, flipU, flipV } = resolved
354
407
 
355
408
  const rawAo = block.ao_data[faceDataIndex] ?? [3, 3, 3, 3]
356
409
  const rawLight = lightCombinedForFace(block, faceDataIndex)
@@ -372,9 +425,9 @@ export function tryBuildShaderCubeInstances(
372
425
  const tintIndex = tintPalette.getTintIndex(eFace.tintindex, cached.blockName, cached.blockProps, biome ?? 'plains')
373
426
 
374
427
  words.push(
375
- packWord0(lx, ly, lz, faceIdx, tintIndex, ao),
428
+ packWord0(lx, ly, lz, faceIdx, tintIndex, ao, flipV),
376
429
  packWord1(lightCombined),
377
- packWord2(texIndex, aoDiagonalFlip, sectionOrigin.x, sectionOrigin.y, sectionOrigin.z),
430
+ packWord2(texIndex, aoDiagonalFlip, sectionOrigin.x, sectionOrigin.y, sectionOrigin.z, flipU),
378
431
  packWord3(sectionOrigin.x, sectionOrigin.z)
379
432
  )
380
433
  }