@forgeax/engine-picking 0.1.30 → 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.
- package/README.md +26 -3
- package/dist/__tests__/pick-triangle.unit.test.d.ts +2 -0
- package/dist/__tests__/pick-triangle.unit.test.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +244 -12
- package/dist/index.mjs.map +1 -1
- package/dist/pick-triangle.d.ts +49 -0
- package/dist/pick-triangle.d.ts.map +1 -0
- package/dist/pick-vertex.d.ts.map +1 -1
- package/package.json +10 -10
- package/src/__tests__/pick-triangle.unit.test.ts +292 -0
- package/src/index.ts +7 -0
- package/src/pick-triangle.ts +347 -0
- package/src/pick-vertex.ts +11 -8
|
@@ -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
|
+
}
|
package/src/pick-vertex.ts
CHANGED
|
@@ -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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 =
|
|
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;
|