@forgeax/engine-picking 0.1.31 → 0.1.32

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,292 @@
1
+ import { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
4
+ import {
5
+ CAMERA_PROJECTION_PERSPECTIVE,
6
+ Camera,
7
+ InstanceCollectionStore,
8
+ Instances,
9
+ MeshFilter,
10
+ MeshRenderer,
11
+ } from '@forgeax/engine-render';
12
+ import { propagateTransforms, Transform } from '@forgeax/engine-scene';
13
+ import type { Handle, MeshAsset } from '@forgeax/engine-types';
14
+ import { describe, expect, it } from 'vitest';
15
+ import { pickTriangle, type TrianglePickOptions, type TrianglePickResult } from '../pick-triangle';
16
+ import { makeMockShaderRegistry } from './helpers/mock-shader-registry';
17
+
18
+ const VP = 600;
19
+
20
+ function registerMesh(
21
+ world: World,
22
+ assets: AssetRegistry,
23
+ positions: readonly number[],
24
+ indices?: readonly number[],
25
+ topology: 'triangle-list' | 'triangle-strip' = 'triangle-list',
26
+ ) {
27
+ const position = new Float32Array(positions);
28
+ const result = assets.catalog<MeshAsset>(AssetGuid.format(AssetGuid.random()), {
29
+ kind: 'mesh',
30
+ vertices: position,
31
+ ...(indices === undefined ? {} : { indices: new Uint16Array(indices) }),
32
+ attributes: { position },
33
+ submeshes: [
34
+ {
35
+ indexOffset: 0,
36
+ indexCount: indices?.length ?? 0,
37
+ vertexCount: Math.floor(position.length / 3),
38
+ topology,
39
+ materialSlot: 0,
40
+ },
41
+ ],
42
+ materialSlots: [{ slotName: 'Default' }],
43
+ });
44
+ if (!result.ok) throw result.error;
45
+ return world.allocSharedRef('MeshAsset', result.value) as Handle<'MeshAsset', 'shared'>;
46
+ }
47
+
48
+ function makeScene() {
49
+ const world = new World();
50
+ const assets = new AssetRegistry(makeMockShaderRegistry());
51
+ const instanceCollections = new InstanceCollectionStore();
52
+ const material = assets.catalog(AssetGuid.format(AssetGuid.random()), {
53
+ kind: 'material',
54
+ passes: [
55
+ {
56
+ name: 'Forward',
57
+ program: { module: 'forgeax::default-unlit' },
58
+ renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
59
+ },
60
+ ],
61
+ values: { baseColor: [1, 1, 1] },
62
+ });
63
+ if (!material.ok) throw material.error;
64
+ const materialHandle = world.allocSharedRef('MaterialAsset', material.value);
65
+ const camera = world
66
+ .spawn(
67
+ { component: Transform, data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
68
+ {
69
+ component: Camera,
70
+ data: {
71
+ fov: Math.PI / 4,
72
+ aspect: 1,
73
+ near: 0.1,
74
+ far: 100,
75
+ projection: CAMERA_PROJECTION_PERSPECTIVE,
76
+ left: -1,
77
+ right: 1,
78
+ top: 1,
79
+ bottom: -1,
80
+ },
81
+ },
82
+ )
83
+ .unwrap();
84
+ return { world, assets, materialHandle, camera, instanceCollections };
85
+ }
86
+
87
+ function spawnMesh(
88
+ scene: ReturnType<typeof makeScene>,
89
+ mesh: Handle<'MeshAsset', 'shared'>,
90
+ transform: { pos?: [number, number, number]; scale?: [number, number, number] } = {},
91
+ ) {
92
+ return scene.world
93
+ .spawn(
94
+ {
95
+ component: Transform,
96
+ data: {
97
+ pos: transform.pos ?? [0, 0, 0],
98
+ quat: [0, 0, 0, 1],
99
+ scale: transform.scale ?? [1, 1, 1],
100
+ },
101
+ },
102
+ { component: MeshFilter, data: { assetHandle: mesh } },
103
+ { component: MeshRenderer, data: { materials: [scene.materialHandle] } },
104
+ )
105
+ .unwrap();
106
+ }
107
+
108
+ function spawnInstancedMesh(
109
+ scene: ReturnType<typeof makeScene>,
110
+ mesh: Handle<'MeshAsset', 'shared'>,
111
+ translations: readonly number[],
112
+ ) {
113
+ const transforms = new Float32Array(translations.length * 16);
114
+ for (let index = 0; index < translations.length; index += 1) {
115
+ const offset = index * 16;
116
+ transforms[offset] = 1;
117
+ transforms[offset + 5] = 1;
118
+ transforms[offset + 10] = 1;
119
+ transforms[offset + 15] = 1;
120
+ transforms[offset + 12] = translations[index] as number;
121
+ }
122
+ const collection = scene.instanceCollections.create({ transforms }).unwrap();
123
+ return scene.world
124
+ .spawn(
125
+ {
126
+ component: Transform,
127
+ data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
128
+ },
129
+ { component: MeshFilter, data: { assetHandle: mesh } },
130
+ { component: MeshRenderer, data: { materials: [scene.materialHandle] } },
131
+ { component: Instances, data: { collectionId: collection.collectionId } },
132
+ )
133
+ .unwrap();
134
+ }
135
+
136
+ function unsupportedSkinnedMesh(scene: ReturnType<typeof makeScene>) {
137
+ const mesh: MeshAsset = {
138
+ kind: 'mesh',
139
+ vertices: new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]),
140
+ attributes: {
141
+ position: new Float32Array([-1, -1, 0, 1, -1, 0, 0, 1, 0]),
142
+ skinIndex: new Uint16Array(12),
143
+ skinWeight: new Float32Array(12),
144
+ },
145
+ aabb: new Float32Array([-1, -1, 0, 1, 1, 0]),
146
+ submeshes: [
147
+ { indexOffset: 0, indexCount: 0, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
148
+ ],
149
+ materialSlots: [{ slotName: 'Default' }],
150
+ };
151
+ return scene.world.allocSharedRef('MeshAsset', mesh) as Handle<'MeshAsset', 'shared'>;
152
+ }
153
+
154
+ function query(
155
+ scene: ReturnType<typeof makeScene>,
156
+ x = VP / 2,
157
+ y = VP / 2,
158
+ options: TrianglePickOptions = {},
159
+ ): TrianglePickResult {
160
+ propagateTransforms(scene.world);
161
+ return pickTriangle(scene.world, scene.camera, x, y, VP, VP, options);
162
+ }
163
+
164
+ describe('pickTriangle', () => {
165
+ it('returns the nearest exact hit and barycentric point', () => {
166
+ const scene = makeScene();
167
+ const near = registerMesh(scene.world, scene.assets, [-1, -1, 0, 1, -1, 0, 0, 1, 0], [0, 1, 2]);
168
+ const far = registerMesh(
169
+ scene.world,
170
+ scene.assets,
171
+ [-1, -1, -2, 1, -1, -2, 0, 1, -2],
172
+ [0, 1, 2],
173
+ );
174
+ const nearEntity = spawnMesh(scene, near);
175
+ spawnMesh(scene, far);
176
+
177
+ const result = query(scene);
178
+ expect(result.status).toBe('hit');
179
+ if (result.status !== 'hit') return;
180
+ expect(result.hit.entity).toBe(nearEntity);
181
+ expect(result.hit.point[2]).toBeCloseTo(0, 4);
182
+ expect(result.hit.distance).toBeCloseTo(4.9, 2);
183
+ expect(result.hit.barycentric.reduce((sum, value) => sum + value, 0)).toBeCloseTo(1, 6);
184
+ expect(result.hit.precision).toBe('triangle');
185
+ });
186
+
187
+ it('uses transformed world vertices, so a wall opening reaches the rear sign', () => {
188
+ const scene = makeScene();
189
+ // Four bars surround the center opening. Their combined AABB covers the
190
+ // whole wall, but no triangle occupies the center ray.
191
+ const wallPositions = [
192
+ -1, 0.5, 0, 1, 0.5, 0, 1, 1, 0, -1, 0.5, 0, 1, 1, 0, -1, 1, 0, -1, -1, 0, 1, -1, 0, 1, -0.5,
193
+ 0, -1, -1, 0, 1, -0.5, 0, -1, -0.5, 0, -1, -0.5, 0, -0.5, -0.5, 0, -0.5, 0.5, 0, -1, -0.5, 0,
194
+ -0.5, 0.5, 0, -1, 0.5, 0, 0.5, -0.5, 0, 1, -0.5, 0, 1, 0.5, 0, 0.5, -0.5, 0, 1, 0.5, 0, 0.5,
195
+ 0.5, 0,
196
+ ];
197
+ const wall = registerMesh(scene.world, scene.assets, wallPositions);
198
+ const sign = registerMesh(
199
+ scene.world,
200
+ scene.assets,
201
+ [-0.5, -0.5, -2, 0.5, -0.5, -2, 0.5, 0.5, -2, -0.5, 0.5, -2],
202
+ );
203
+ const wallEntity = spawnMesh(scene, wall);
204
+ const signEntity = spawnMesh(scene, sign);
205
+
206
+ const result = query(scene);
207
+ expect(result.status).toBe('hit');
208
+ if (result.status !== 'hit') return;
209
+ expect(result.hit.entity).toBe(signEntity);
210
+ expect(result.hit.entity).not.toBe(wallEntity);
211
+ });
212
+
213
+ it('handles non-uniform transformed instances and reports unavailable CPU/skinned data', () => {
214
+ const scene = makeScene();
215
+ const mesh = registerMesh(
216
+ scene.world,
217
+ scene.assets,
218
+ [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
219
+ [0, 1, 2],
220
+ );
221
+ const entity = spawnMesh(scene, mesh, { pos: [0, 0, 0], scale: [2, 3, 1] });
222
+ const transformed = query(scene);
223
+ expect(transformed.status).toBe('hit');
224
+ if (transformed.status === 'hit') expect(transformed.hit.entity).toBe(entity);
225
+
226
+ const unsupported = unsupportedSkinnedMesh(scene);
227
+ spawnMesh(scene, unsupported);
228
+ const result = query(scene);
229
+ expect(result.status).toBe('unavailable');
230
+ });
231
+
232
+ it('resolves explicit Instances matrices and identifies the hit ordinal', () => {
233
+ const scene = makeScene();
234
+ const mesh = registerMesh(
235
+ scene.world,
236
+ scene.assets,
237
+ [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
238
+ [0, 1, 2],
239
+ );
240
+ const entity = spawnInstancedMesh(scene, mesh, [5, 0]);
241
+
242
+ const result = query(scene, VP / 2, VP / 2, { instances: scene.instanceCollections });
243
+ expect(result.status).toBe('hit');
244
+ if (result.status !== 'hit') return;
245
+ expect(result.hit.entity).toBe(entity);
246
+ expect(result.hit.instanceIndex).toBe(1);
247
+ });
248
+
249
+ it('fails closed when an explicit Instances resolver is absent', () => {
250
+ const scene = makeScene();
251
+ const mesh = registerMesh(
252
+ scene.world,
253
+ scene.assets,
254
+ [-0.5, -0.5, 0, 0.5, -0.5, 0, 0, 0.5, 0],
255
+ [0, 1, 2],
256
+ );
257
+ spawnInstancedMesh(scene, mesh, [0]);
258
+
259
+ expect(query(scene).status).toBe('unavailable');
260
+ const result = query(scene);
261
+ expect(result.status).toBe('unavailable');
262
+ if (result.status === 'unavailable') {
263
+ expect(result.reason).toBe('instance-transforms-unavailable');
264
+ }
265
+ });
266
+
267
+ it('returns a miss for an indexed/non-indexed mesh outside the ray', () => {
268
+ const scene = makeScene();
269
+ const mesh = registerMesh(scene.world, scene.assets, [-1, -1, 0, 1, -1, 0, 0, 1, 0]);
270
+ spawnMesh(scene, mesh, { pos: [10, 0, 0] });
271
+ expect(query(scene).status).toBe('miss');
272
+ });
273
+
274
+ it('intersects indexed triangle strips with alternating winding', () => {
275
+ const scene = makeScene();
276
+ const positions = [-1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0];
277
+ const indexed = registerMesh(
278
+ scene.world,
279
+ scene.assets,
280
+ positions,
281
+ [0, 1, 2, 3],
282
+ 'triangle-strip',
283
+ );
284
+ const indexedEntity = spawnMesh(scene, indexed);
285
+
286
+ const result = query(scene);
287
+ expect(result.status).toBe('hit');
288
+ if (result.status !== 'hit') return;
289
+ expect(result.hit.entity).toBe(indexedEntity);
290
+ expect(result.hit.triangleIndex).toBe(0);
291
+ });
292
+ });
package/src/index.ts CHANGED
@@ -15,6 +15,13 @@ export type { PickErrorCode } from './pick-errors';
15
15
  export { PickError } from './pick-errors';
16
16
  // --- tile-cell pick (Tilemap query) ---
17
17
  export { type PickTileError, type PickTileHit, pickTile } from './pick-tile';
18
+ export {
19
+ pickTriangle,
20
+ type TriangleHit,
21
+ type TrianglePickOptions,
22
+ type TrianglePickResult,
23
+ type TrianglePickUnavailableReason,
24
+ } from './pick-triangle';
18
25
  // --- vertex-level pick (per-entity + full-scene) ---
19
26
  export type { VertexHit } from './pick-vertex';
20
27
  export { pickVertex, pickVertexOnEntity } from './pick-vertex';
@@ -0,0 +1,347 @@
1
+ // pick-triangle.ts — precise screen ray / triangle query.
2
+ //
3
+ // This is the precise companion to pick()'s inexpensive world-AABB query. CPU
4
+ // triangle vertices are transformed into world space before testing the shared
5
+ // world ray. That keeps non-uniformly transformed instances and nearest-
6
+ // occluder ordering correct.
7
+
8
+ import { resolveAssetHandle } from '@forgeax/engine-assets-runtime';
9
+ import type { EntityHandle, World } from '@forgeax/engine-ecs';
10
+ import { mat4, ray, type Vec3Like, vec3 } from '@forgeax/engine-math';
11
+ import type { InstanceCollectionId, InstanceCollectionStore } from '@forgeax/engine-render';
12
+ import { Instances, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
13
+ import { GlobalTransform, Transform } from '@forgeax/engine-scene';
14
+ import type { MeshAsset } from '@forgeax/engine-types';
15
+ import { toShared } from '@forgeax/engine-types';
16
+ import { computeScreenRay, readWorldMatrix } from './pick-core';
17
+
18
+ export interface TriangleHit {
19
+ readonly entity: EntityHandle;
20
+ /** Zero-based triangle index across supported triangle submeshes. */
21
+ readonly triangleIndex: number;
22
+ /** Exact world-space intersection point. */
23
+ readonly point: Vec3Like;
24
+ /** Distance from the screen ray origin in world units. */
25
+ readonly distance: number;
26
+ /** Barycentric weights for the triangle's first, second, and third vertices. */
27
+ readonly barycentric: readonly [number, number, number];
28
+ /** Present when the caller supplied the registry's payload identity resolver. */
29
+ readonly assetGuid?: string;
30
+ /** Zero-based explicit instance ordinal when the entity owns Instances. */
31
+ readonly instanceIndex?: number;
32
+ readonly precision: 'triangle';
33
+ }
34
+
35
+ export type TrianglePickUnavailableReason =
36
+ | 'cpu-geometry-unavailable'
37
+ | 'skinned-pose-unavailable'
38
+ | 'instance-transforms-unavailable';
39
+
40
+ export type TrianglePickResult =
41
+ | { readonly status: 'hit'; readonly hit: TriangleHit }
42
+ | { readonly status: 'miss'; readonly precision: 'triangle' }
43
+ | {
44
+ readonly status: 'unavailable';
45
+ readonly precision: 'unavailable';
46
+ readonly reason: TrianglePickUnavailableReason;
47
+ readonly entities: readonly EntityHandle[];
48
+ };
49
+
50
+ export interface TrianglePickOptions {
51
+ /** Resolve a loaded MeshAsset payload to its existing Catalog GUID. */
52
+ readonly assetGuidOf?: (asset: MeshAsset) => string | undefined;
53
+ /** Read explicit instance matrices without advancing the renderer upload cursor. */
54
+ readonly instances?: Pick<InstanceCollectionStore, 'peek'>;
55
+ }
56
+
57
+ function narrowPosition(position: MeshAsset['attributes']['position']): Float32Array | undefined {
58
+ if (position instanceof Float32Array) return position;
59
+ if (position instanceof ArrayBuffer) return new Float32Array(position);
60
+ return undefined;
61
+ }
62
+
63
+ function worldAabbHit(
64
+ screenRay: ray.Ray,
65
+ aabb: MeshAsset['aabb'],
66
+ worldMatrix: mat4.Mat4Like,
67
+ ): boolean {
68
+ if (aabb === undefined || (aabb[0] as number) > (aabb[3] as number)) return true;
69
+ const corners: readonly Vec3Like[] = [
70
+ [aabb[0] as number, aabb[1] as number, aabb[2] as number],
71
+ [aabb[3] as number, aabb[1] as number, aabb[2] as number],
72
+ [aabb[0] as number, aabb[4] as number, aabb[2] as number],
73
+ [aabb[3] as number, aabb[4] as number, aabb[2] as number],
74
+ [aabb[0] as number, aabb[1] as number, aabb[5] as number],
75
+ [aabb[3] as number, aabb[1] as number, aabb[5] as number],
76
+ [aabb[0] as number, aabb[4] as number, aabb[5] as number],
77
+ [aabb[3] as number, aabb[4] as number, aabb[5] as number],
78
+ ];
79
+ const world = vec3.create();
80
+ let minX = Infinity;
81
+ let minY = Infinity;
82
+ let minZ = Infinity;
83
+ let maxX = -Infinity;
84
+ let maxY = -Infinity;
85
+ let maxZ = -Infinity;
86
+ for (const corner of corners) {
87
+ mat4.transformPoint(world, worldMatrix, corner);
88
+ minX = Math.min(minX, world[0] as number);
89
+ minY = Math.min(minY, world[1] as number);
90
+ minZ = Math.min(minZ, world[2] as number);
91
+ maxX = Math.max(maxX, world[0] as number);
92
+ maxY = Math.max(maxY, world[1] as number);
93
+ maxZ = Math.max(maxZ, world[2] as number);
94
+ }
95
+ return ray.rayAabbIntersects(screenRay, [minX, minY, minZ, maxX, maxY, maxZ]).hit;
96
+ }
97
+
98
+ function pointOnRay(screenRay: ray.Ray, point: Vec3Like): number {
99
+ const dx = (point[0] as number) - (screenRay[0] as number);
100
+ const dy = (point[1] as number) - (screenRay[1] as number);
101
+ const dz = (point[2] as number) - (screenRay[2] as number);
102
+ return (
103
+ dx * (screenRay[3] as number) + dy * (screenRay[4] as number) + dz * (screenRay[5] as number)
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Return the nearest exact triangle hit for a viewport ray.
109
+ *
110
+ * A result of `unavailable` is deliberate: a skinned mesh or a mesh without
111
+ * CPU position data cannot make a truthful nearest-occluder claim. The query
112
+ * only reports that state when such an entity's broad-phase AABB intersects
113
+ * the ray, so unrelated unsupported meshes do not hide a valid hit.
114
+ */
115
+ export function pickTriangle(
116
+ world: World,
117
+ cameraEntity: EntityHandle,
118
+ screenX: number,
119
+ screenY: number,
120
+ viewportWidth: number,
121
+ viewportHeight: number,
122
+ options: TrianglePickOptions = {},
123
+ ): TrianglePickResult {
124
+ const screen = computeScreenRay(
125
+ world,
126
+ cameraEntity,
127
+ screenX,
128
+ screenY,
129
+ viewportWidth,
130
+ viewportHeight,
131
+ );
132
+ if (screen === undefined) return { status: 'miss', precision: 'triangle' };
133
+
134
+ const origin = screen.ray;
135
+ const query = world
136
+ .query({
137
+ read: [Transform, GlobalTransform, MeshFilter, MeshRenderer],
138
+ optional: [Instances],
139
+ })
140
+ .unwrap();
141
+ let best: TriangleHit | undefined;
142
+ let unsupportedReason: TrianglePickUnavailableReason | undefined;
143
+ const unsupportedEntities: EntityHandle[] = [];
144
+
145
+ for (const row of query) {
146
+ const rawHandle = Math.round(row.get(MeshFilter).assetHandle as number);
147
+ if (rawHandle === 0) continue;
148
+ const resolved = resolveAssetHandle<MeshAsset>(world, toShared<'MeshAsset'>(rawHandle));
149
+ if (!resolved.ok) continue;
150
+ const mesh = resolved.value;
151
+ const entity = row.entity;
152
+ const entityWorldMatrix = readWorldMatrix(world, entity);
153
+ if (entityWorldMatrix === undefined) continue;
154
+ const instancesData = row.get(Instances);
155
+ let instanceTransforms: Float32Array | undefined;
156
+ let instanceCount = 1;
157
+ if (instancesData !== undefined) {
158
+ if (options.instances === undefined) {
159
+ unsupportedReason ??= 'instance-transforms-unavailable';
160
+ unsupportedEntities.push(entity);
161
+ continue;
162
+ }
163
+ const snapshot = options.instances.peek(instancesData.collectionId as InstanceCollectionId);
164
+ if (!snapshot.ok) {
165
+ unsupportedReason ??= 'instance-transforms-unavailable';
166
+ unsupportedEntities.push(entity);
167
+ continue;
168
+ }
169
+ instanceTransforms = snapshot.value.transforms;
170
+ instanceCount = snapshot.value.count;
171
+ }
172
+
173
+ const positions = narrowPosition(mesh.attributes.position);
174
+ const skinned =
175
+ mesh.attributes.skinIndex !== undefined && mesh.attributes.skinWeight !== undefined;
176
+ if (skinned) {
177
+ let intersects = false;
178
+ for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex += 1) {
179
+ const drawWorld =
180
+ instanceTransforms === undefined
181
+ ? (entityWorldMatrix as unknown as mat4.Mat4Like)
182
+ : mat4.multiply(
183
+ mat4.create(),
184
+ entityWorldMatrix as unknown as mat4.Mat4Like,
185
+ instanceTransforms.subarray(instanceIndex * 16, instanceIndex * 16 + 16),
186
+ );
187
+ if (worldAabbHit(origin, mesh.aabb, drawWorld)) {
188
+ intersects = true;
189
+ break;
190
+ }
191
+ }
192
+ if (!intersects) continue;
193
+ unsupportedReason ??= 'skinned-pose-unavailable';
194
+ unsupportedEntities.push(entity);
195
+ continue;
196
+ }
197
+ if (positions === undefined || positions.length < 3) {
198
+ let intersects = false;
199
+ for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex += 1) {
200
+ const drawWorld =
201
+ instanceTransforms === undefined
202
+ ? (entityWorldMatrix as unknown as mat4.Mat4Like)
203
+ : mat4.multiply(
204
+ mat4.create(),
205
+ entityWorldMatrix as unknown as mat4.Mat4Like,
206
+ instanceTransforms.subarray(instanceIndex * 16, instanceIndex * 16 + 16),
207
+ );
208
+ if (worldAabbHit(origin, mesh.aabb, drawWorld)) {
209
+ intersects = true;
210
+ break;
211
+ }
212
+ }
213
+ if (!intersects) continue;
214
+ unsupportedReason ??= 'cpu-geometry-unavailable';
215
+ unsupportedEntities.push(entity);
216
+ continue;
217
+ }
218
+
219
+ for (let instanceIndex = 0; instanceIndex < instanceCount; instanceIndex += 1) {
220
+ const drawWorld =
221
+ instanceTransforms === undefined
222
+ ? (entityWorldMatrix as unknown as mat4.Mat4Like)
223
+ : mat4.multiply(
224
+ mat4.create(),
225
+ entityWorldMatrix as unknown as mat4.Mat4Like,
226
+ instanceTransforms.subarray(instanceIndex * 16, instanceIndex * 16 + 16),
227
+ );
228
+ if (!worldAabbHit(origin, mesh.aabb, drawWorld)) continue;
229
+
230
+ const worldA = vec3.create();
231
+ const worldB = vec3.create();
232
+ const worldC = vec3.create();
233
+ const worldPoint = vec3.create();
234
+ let triangleIndex = 0;
235
+ const indices = mesh.indices;
236
+ const maxVertex = Math.floor(positions.length / 3) - 1;
237
+ const test = (i0: number, i1: number, i2: number, index: number): void => {
238
+ if (i0 < 0 || i1 < 0 || i2 < 0 || i0 > maxVertex || i1 > maxVertex || i2 > maxVertex)
239
+ return;
240
+ const a = [
241
+ positions[i0 * 3] as number,
242
+ positions[i0 * 3 + 1] as number,
243
+ positions[i0 * 3 + 2] as number,
244
+ ] as Vec3Like;
245
+ const b = [
246
+ positions[i1 * 3] as number,
247
+ positions[i1 * 3 + 1] as number,
248
+ positions[i1 * 3 + 2] as number,
249
+ ] as Vec3Like;
250
+ const c = [
251
+ positions[i2 * 3] as number,
252
+ positions[i2 * 3 + 1] as number,
253
+ positions[i2 * 3 + 2] as number,
254
+ ] as Vec3Like;
255
+ mat4.transformPoint(worldA, drawWorld, a);
256
+ mat4.transformPoint(worldB, drawWorld, b);
257
+ mat4.transformPoint(worldC, drawWorld, c);
258
+ const hit = ray.rayTriangleIntersects(origin, worldA, worldB, worldC);
259
+ if (!hit.hit) return;
260
+ // The local ray origin is stored in the first three slots; construct the
261
+ // point explicitly to avoid relying on a mutable Ray alias.
262
+ worldPoint[0] = (origin[0] as number) + (origin[3] as number) * hit.t;
263
+ worldPoint[1] = (origin[1] as number) + (origin[4] as number) * hit.t;
264
+ worldPoint[2] = (origin[2] as number) + (origin[5] as number) * hit.t;
265
+ const distance = pointOnRay(origin, worldPoint);
266
+ if (
267
+ !Number.isFinite(distance) ||
268
+ distance < 0 ||
269
+ (best !== undefined && distance >= best.distance)
270
+ )
271
+ return;
272
+ const assetGuid = options.assetGuidOf?.(mesh);
273
+ best = {
274
+ entity,
275
+ triangleIndex: index,
276
+ point: [worldPoint[0], worldPoint[1], worldPoint[2]] as unknown as Vec3Like,
277
+ distance,
278
+ barycentric: [1 - hit.u - hit.v, hit.u, hit.v],
279
+ ...(assetGuid === undefined ? {} : { assetGuid }),
280
+ ...(instanceTransforms === undefined ? {} : { instanceIndex }),
281
+ precision: 'triangle',
282
+ };
283
+ };
284
+
285
+ for (const submesh of mesh.submeshes) {
286
+ if (submesh.topology !== 'triangle-list' && submesh.topology !== 'triangle-strip') continue;
287
+ const strip = submesh.topology === 'triangle-strip';
288
+ if (indices !== undefined && indices.length > 0 && submesh.indexCount > 0) {
289
+ const count = strip
290
+ ? Math.max(0, submesh.indexCount - 2)
291
+ : Math.floor(submesh.indexCount / 3);
292
+ for (let localTriangle = 0; localTriangle < count; localTriangle += 1) {
293
+ if (strip) {
294
+ const base = submesh.indexOffset + localTriangle;
295
+ const i0 = indices[base] as number;
296
+ const i1 = indices[base + 1] as number;
297
+ const i2 = indices[base + 2] as number;
298
+ // WebGPU alternates strip winding after every vertex. The
299
+ // intersection is double-sided, but preserving the primitive's
300
+ // authored order keeps barycentrics and triangle identity honest.
301
+ if ((localTriangle & 1) === 0) test(i0, i1, i2, triangleIndex);
302
+ else test(i2, i1, i0, triangleIndex);
303
+ } else {
304
+ const base = submesh.indexOffset + localTriangle * 3;
305
+ test(
306
+ indices[base] as number,
307
+ indices[base + 1] as number,
308
+ indices[base + 2] as number,
309
+ triangleIndex,
310
+ );
311
+ }
312
+ triangleIndex += 1;
313
+ }
314
+ } else {
315
+ const count = strip
316
+ ? Math.max(0, submesh.vertexCount - 2)
317
+ : Math.floor(submesh.vertexCount / 3);
318
+ for (let localTriangle = 0; localTriangle < count; localTriangle += 1) {
319
+ if (strip) {
320
+ const i0 = localTriangle;
321
+ const i1 = localTriangle + 1;
322
+ const i2 = localTriangle + 2;
323
+ if ((localTriangle & 1) === 0) test(i0, i1, i2, triangleIndex);
324
+ else test(i2, i1, i0, triangleIndex);
325
+ } else {
326
+ const base = localTriangle * 3;
327
+ test(base, base + 1, base + 2, triangleIndex);
328
+ }
329
+ triangleIndex += 1;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ if (unsupportedReason !== undefined) {
337
+ return {
338
+ status: 'unavailable',
339
+ precision: 'unavailable',
340
+ reason: unsupportedReason,
341
+ entities: unsupportedEntities,
342
+ };
343
+ }
344
+ return best === undefined
345
+ ? { status: 'miss', precision: 'triangle' }
346
+ : { status: 'hit', hit: best };
347
+ }
@@ -293,12 +293,16 @@ function collectVertexHits(
293
293
  const cy = positions[i2 * 3 + 1] as number;
294
294
  const cz = positions[i2 * 3 + 2] as number;
295
295
 
296
- const triResult = ray.rayTriangleIntersects(
297
- r,
298
- [ax, ay, az] as unknown as Vec3Like,
299
- [bx, by, bz] as unknown as Vec3Like,
300
- [cx, cy, cz] as unknown as Vec3Like,
301
- );
296
+ // The screen ray is in world space. Transform the triangle into that same
297
+ // space before testing it; testing local positions against a world ray
298
+ // makes translated/rotated/non-uniform instances return false hits.
299
+ const worldA = vec3.create();
300
+ const worldB = vec3.create();
301
+ const worldC = vec3.create();
302
+ mat4.transformPoint(worldA, entityWMLike, [ax, ay, az] as unknown as Vec3Like);
303
+ mat4.transformPoint(worldB, entityWMLike, [bx, by, bz] as unknown as Vec3Like);
304
+ mat4.transformPoint(worldC, entityWMLike, [cx, cy, cz] as unknown as Vec3Like);
305
+ const triResult = ray.rayTriangleIntersects(r, worldA, worldB, worldC);
302
306
 
303
307
  if (!triResult.hit) return;
304
308
 
@@ -310,8 +314,7 @@ function collectVertexHits(
310
314
  if (Number.isNaN(lx) || Number.isNaN(ly) || Number.isNaN(lz)) continue;
311
315
  if (!Number.isFinite(lx) || !Number.isFinite(ly) || !Number.isFinite(lz)) continue;
312
316
 
313
- const worldVec = vec3.create();
314
- mat4.transformPoint(worldVec, entityWMLike, [lx, ly, lz] as unknown as Vec3Like);
317
+ const worldVec = vi === i0 ? worldA : vi === i1 ? worldB : worldC;
315
318
  const wx = worldVec[0] as number;
316
319
  const wy = worldVec[1] as number;
317
320
  const wz = worldVec[2] as number;