@hello-terrain/react 0.0.0-alpha.10

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/dist/index.mjs ADDED
@@ -0,0 +1,598 @@
1
+ import { jsx } from 'react/jsx-runtime';
2
+ import { terrainTasks, elevationFn, rootSize, origin, maxLevel, maxNodes, innerTileSegments, skirtScale, elevationScale, surface, terrainFieldFilter, quadtreeUpdate, terrainGraph, TerrainMesh } from '@hello-terrain/three';
3
+ import { useFrame } from '@react-three/fiber';
4
+ import { createContext, useContext, useRef, useLayoutEffect, useCallback, useMemo, useState, useEffect, isValidElement, cloneElement } from 'react';
5
+ import { task } from '@hello-terrain/work';
6
+ import { Vector3 } from 'three';
7
+
8
+ const TerrainContext = createContext(null);
9
+ function TerrainProvider({ value, children }) {
10
+ return /* @__PURE__ */ jsx(TerrainContext.Provider, { value, children });
11
+ }
12
+ function useTerrainContext() {
13
+ const value = useContext(TerrainContext);
14
+ if (!value) {
15
+ throw new Error("useTerrainContext must be used within a TerrainProvider");
16
+ }
17
+ return value;
18
+ }
19
+
20
+ function createSyncTerrainRuntimeTask(runtime) {
21
+ return task((get, work) => {
22
+ const query = get(terrainTasks.terrainQuery).query;
23
+ const raycast = get(terrainTasks.terrainRaycast);
24
+ return work(() => {
25
+ runtime.query = query;
26
+ runtime.raycast = raycast;
27
+ return runtime;
28
+ });
29
+ }).displayName("syncTerrainRuntimeTask").cache("none");
30
+ }
31
+ function createSyncTerrainNodesTask(getTerrainNodes, setTerrainNodes, getReady, setReady) {
32
+ return task((get, work) => {
33
+ const positionNode = get(terrainTasks.positionNode);
34
+ return work(() => {
35
+ const nextReady = positionNode != null;
36
+ const terrainNodes = getTerrainNodes();
37
+ if (terrainNodes.positionNode !== positionNode) {
38
+ setTerrainNodes({
39
+ positionNode
40
+ });
41
+ }
42
+ if (getReady() !== nextReady) {
43
+ setReady(nextReady);
44
+ }
45
+ return {
46
+ positionNode,
47
+ ready: nextReady
48
+ };
49
+ });
50
+ }).displayName("syncTerrainNodesTask").cache("none");
51
+ }
52
+
53
+ function resetOrSet(graph, ownedParamIds, ref, value, setValue) {
54
+ if (value === void 0) {
55
+ if (ownedParamIds.has(ref.id)) {
56
+ graph.reset(ref);
57
+ ownedParamIds.delete(ref.id);
58
+ }
59
+ return;
60
+ }
61
+ graph.set(ref, setValue);
62
+ ownedParamIds.add(ref.id);
63
+ }
64
+ function useTerrainParams(graph, options) {
65
+ const ownedParamIdsRef = useRef(/* @__PURE__ */ new Set());
66
+ const {
67
+ rootSize: nextRootSize,
68
+ origin: nextOrigin,
69
+ maxLevel: nextMaxLevel,
70
+ maxNodes: nextMaxNodes,
71
+ innerTileSegments: nextInnerTileSegments,
72
+ skirtScale: nextSkirtScale,
73
+ elevationScale: nextElevationScale,
74
+ elevation,
75
+ surface: nextSurface,
76
+ terrainFieldFilter: nextTerrainFieldFilter
77
+ } = options;
78
+ useLayoutEffect(() => {
79
+ ownedParamIdsRef.current.clear();
80
+ }, [graph]);
81
+ useLayoutEffect(() => {
82
+ const ownedParamIds = ownedParamIdsRef.current;
83
+ resetOrSet(
84
+ graph,
85
+ ownedParamIds,
86
+ rootSize,
87
+ nextRootSize,
88
+ () => nextRootSize
89
+ );
90
+ resetOrSet(graph, ownedParamIds, origin, nextOrigin, () => ({
91
+ x: nextOrigin?.x ?? 0,
92
+ y: nextOrigin?.y ?? 0,
93
+ z: nextOrigin?.z ?? 0
94
+ }));
95
+ resetOrSet(
96
+ graph,
97
+ ownedParamIds,
98
+ maxLevel,
99
+ nextMaxLevel,
100
+ () => nextMaxLevel
101
+ );
102
+ resetOrSet(
103
+ graph,
104
+ ownedParamIds,
105
+ maxNodes,
106
+ nextMaxNodes,
107
+ () => nextMaxNodes
108
+ );
109
+ resetOrSet(
110
+ graph,
111
+ ownedParamIds,
112
+ innerTileSegments,
113
+ nextInnerTileSegments,
114
+ () => nextInnerTileSegments
115
+ );
116
+ resetOrSet(
117
+ graph,
118
+ ownedParamIds,
119
+ skirtScale,
120
+ nextSkirtScale,
121
+ () => nextSkirtScale
122
+ );
123
+ resetOrSet(
124
+ graph,
125
+ ownedParamIds,
126
+ elevationScale,
127
+ nextElevationScale,
128
+ () => nextElevationScale
129
+ );
130
+ resetOrSet(
131
+ graph,
132
+ ownedParamIds,
133
+ surface,
134
+ nextSurface,
135
+ () => nextSurface
136
+ );
137
+ resetOrSet(
138
+ graph,
139
+ ownedParamIds,
140
+ terrainFieldFilter,
141
+ nextTerrainFieldFilter,
142
+ () => nextTerrainFieldFilter
143
+ );
144
+ if (elevation === void 0) {
145
+ if (ownedParamIds.has(elevationFn.id)) {
146
+ graph.reset(elevationFn);
147
+ ownedParamIds.delete(elevationFn.id);
148
+ }
149
+ } else {
150
+ graph.set(elevationFn, () => elevation);
151
+ ownedParamIds.add(elevationFn.id);
152
+ }
153
+ }, [
154
+ graph,
155
+ nextRootSize,
156
+ nextOrigin,
157
+ nextMaxLevel,
158
+ nextMaxNodes,
159
+ nextInnerTileSegments,
160
+ nextSkirtScale,
161
+ nextElevationScale,
162
+ elevation,
163
+ nextSurface,
164
+ nextTerrainFieldFilter
165
+ ]);
166
+ }
167
+
168
+ const WEBGPU_RENDERER_ERROR = "@hello-terrain/react requires a WebGPURenderer on <Canvas gl={...}>.";
169
+ const GRAPH_RUN_ERROR = "@hello-terrain/react terrain graph run failed.";
170
+ function toVector3Like(state, getCameraOrigin) {
171
+ return getCameraOrigin?.(state) ?? state.camera.position;
172
+ }
173
+ function getTerrainRunnerErrorKey(error) {
174
+ if (error instanceof Error) {
175
+ return `${error.name}:${error.message}`;
176
+ }
177
+ return String(error);
178
+ }
179
+ function isWebGpuRenderer(renderer) {
180
+ return typeof renderer === "object" && renderer !== null && "backend" in renderer;
181
+ }
182
+ function useTerrainRunner({
183
+ graph,
184
+ targets,
185
+ getCameraOrigin,
186
+ cameraHysteresis = 0.05
187
+ }) {
188
+ const graphRef = useRef(graph);
189
+ const targetsRef = useRef(targets);
190
+ const getCameraOriginRef = useRef(getCameraOrigin);
191
+ const lastCameraOriginRef = useRef(null);
192
+ const runningRef = useRef(false);
193
+ const generationRef = useRef(0);
194
+ const runAbortControllerRef = useRef(null);
195
+ const runPromiseRef = useRef(null);
196
+ const lastErrorKeyRef = useRef(null);
197
+ const reportError = useCallback(
198
+ (error, errorKey) => {
199
+ const nextErrorKey = errorKey ?? getTerrainRunnerErrorKey(error);
200
+ if (lastErrorKeyRef.current === nextErrorKey) return;
201
+ lastErrorKeyRef.current = nextErrorKey;
202
+ console.error(error);
203
+ },
204
+ []
205
+ );
206
+ const clearError = useCallback(() => {
207
+ lastErrorKeyRef.current = null;
208
+ }, []);
209
+ const stopCurrentRun = useCallback(async () => {
210
+ const activeController = runAbortControllerRef.current;
211
+ if (activeController && !activeController.signal.aborted) {
212
+ activeController.abort(new Error("Terrain runner stopped"));
213
+ }
214
+ const activeRun = runPromiseRef.current;
215
+ if (activeRun) {
216
+ await activeRun.catch(() => {
217
+ });
218
+ }
219
+ }, []);
220
+ const updateCameraOrigin = useCallback(
221
+ (state, activeGraph) => {
222
+ const activeGetCameraOrigin = getCameraOriginRef.current;
223
+ const cameraOrigin = toVector3Like(state, activeGetCameraOrigin);
224
+ const nextOrigin = new Vector3(
225
+ cameraOrigin.x,
226
+ cameraOrigin.y,
227
+ cameraOrigin.z
228
+ );
229
+ const lastOrigin = lastCameraOriginRef.current;
230
+ const hysteresisSq = cameraHysteresis * cameraHysteresis;
231
+ if (!lastOrigin || lastOrigin.distanceToSquared(nextOrigin) >= hysteresisSq) {
232
+ activeGraph.set(quadtreeUpdate, (prev) => {
233
+ prev.cameraOrigin.x = nextOrigin.x;
234
+ prev.cameraOrigin.y = nextOrigin.y;
235
+ prev.cameraOrigin.z = nextOrigin.z;
236
+ return prev;
237
+ });
238
+ lastCameraOriginRef.current = nextOrigin;
239
+ }
240
+ },
241
+ [cameraHysteresis]
242
+ );
243
+ const finishRun = useCallback(
244
+ (activeRunController, activeGeneration) => {
245
+ if (runAbortControllerRef.current === activeRunController) {
246
+ runAbortControllerRef.current = null;
247
+ runPromiseRef.current = null;
248
+ }
249
+ if (generationRef.current === activeGeneration) {
250
+ runningRef.current = false;
251
+ }
252
+ },
253
+ []
254
+ );
255
+ useLayoutEffect(() => {
256
+ void stopCurrentRun();
257
+ graphRef.current = graph;
258
+ targetsRef.current = targets;
259
+ getCameraOriginRef.current = getCameraOrigin;
260
+ lastCameraOriginRef.current = null;
261
+ runningRef.current = false;
262
+ generationRef.current += 1;
263
+ clearError();
264
+ return () => {
265
+ generationRef.current += 1;
266
+ runningRef.current = false;
267
+ void stopCurrentRun();
268
+ };
269
+ }, [clearError, getCameraOrigin, graph, stopCurrentRun, targets]);
270
+ useFrame((state) => {
271
+ if (runningRef.current) return;
272
+ const renderer = state.gl;
273
+ if (!isWebGpuRenderer(renderer)) {
274
+ reportError(
275
+ new Error(WEBGPU_RENDERER_ERROR),
276
+ `renderer:${WEBGPU_RENDERER_ERROR}`
277
+ );
278
+ return;
279
+ }
280
+ const activeGeneration = generationRef.current;
281
+ const activeRunController = new AbortController();
282
+ runningRef.current = true;
283
+ runAbortControllerRef.current = activeRunController;
284
+ const runPromise = (async () => {
285
+ try {
286
+ const activeGraph = graphRef.current;
287
+ const activeTargets = targetsRef.current;
288
+ updateCameraOrigin(state, activeGraph);
289
+ const report = await activeGraph.run({
290
+ targets: activeTargets,
291
+ signal: activeRunController.signal,
292
+ resources: {
293
+ renderer
294
+ }
295
+ });
296
+ if (report.status === "error") {
297
+ reportError(new Error(GRAPH_RUN_ERROR), `graph:${GRAPH_RUN_ERROR}`);
298
+ return;
299
+ }
300
+ clearError();
301
+ } catch (error) {
302
+ if (activeRunController.signal.aborted) return;
303
+ reportError(error);
304
+ } finally {
305
+ finishRun(activeRunController, activeGeneration);
306
+ }
307
+ })();
308
+ runPromiseRef.current = runPromise;
309
+ });
310
+ return stopCurrentRun;
311
+ }
312
+
313
+ function useTerrain(options = {}) {
314
+ const runtime = useMemo(
315
+ () => ({
316
+ query: null,
317
+ raycast: null
318
+ }),
319
+ []
320
+ );
321
+ const isMountedRef = useRef(true);
322
+ const graphLifecycleRef = useRef(0);
323
+ const terrainNodesRef = useRef({
324
+ positionNode: null
325
+ });
326
+ const [terrainNodes, setTerrainNodes] = useState(terrainNodesRef.current);
327
+ const readyRef = useRef(false);
328
+ const [ready, setReady] = useState(false);
329
+ const emitTerrainNodes = useCallback(
330
+ (nextTerrainNodes) => {
331
+ if (!isMountedRef.current) return;
332
+ setTerrainNodes((prevTerrainNodes) => {
333
+ if (prevTerrainNodes.positionNode === nextTerrainNodes.positionNode) {
334
+ terrainNodesRef.current = prevTerrainNodes;
335
+ return prevTerrainNodes;
336
+ }
337
+ terrainNodesRef.current = nextTerrainNodes;
338
+ return nextTerrainNodes;
339
+ });
340
+ },
341
+ []
342
+ );
343
+ const getTerrainNodes = useCallback(
344
+ () => terrainNodesRef.current,
345
+ []
346
+ );
347
+ const emitReady = useCallback((nextReady) => {
348
+ if (!isMountedRef.current) return;
349
+ readyRef.current = nextReady;
350
+ setReady((prevReady) => prevReady === nextReady ? prevReady : nextReady);
351
+ }, []);
352
+ const getReady = useCallback(
353
+ () => readyRef.current,
354
+ []
355
+ );
356
+ const syncTerrainRuntimeTask = useMemo(
357
+ () => createSyncTerrainRuntimeTask(runtime),
358
+ [runtime]
359
+ );
360
+ const syncTerrainNodesTask = useMemo(
361
+ () => createSyncTerrainNodesTask(
362
+ getTerrainNodes,
363
+ emitTerrainNodes,
364
+ getReady,
365
+ emitReady
366
+ ),
367
+ [emitReady, emitTerrainNodes, getReady, getTerrainNodes]
368
+ );
369
+ const graph = useMemo(() => {
370
+ const nextGraph = terrainGraph();
371
+ for (const task of options.tasks ?? []) {
372
+ nextGraph.add(task);
373
+ }
374
+ nextGraph.add(syncTerrainRuntimeTask);
375
+ nextGraph.add(syncTerrainNodesTask);
376
+ return nextGraph;
377
+ }, [options.tasks, syncTerrainNodesTask, syncTerrainRuntimeTask]);
378
+ const runnerTargets = useMemo(
379
+ () => {
380
+ const userTasks = options.tasks ?? [];
381
+ return [
382
+ ...userTasks,
383
+ terrainTasks.executeCompute,
384
+ terrainTasks.terrainReadback,
385
+ syncTerrainRuntimeTask,
386
+ syncTerrainNodesTask
387
+ ];
388
+ },
389
+ [options.tasks, syncTerrainNodesTask, syncTerrainRuntimeTask]
390
+ );
391
+ const stopTerrainRunner = useTerrainRunner({
392
+ graph,
393
+ targets: runnerTargets,
394
+ getCameraOrigin: options.getCameraOrigin,
395
+ cameraHysteresis: options.cameraHysteresis
396
+ });
397
+ useEffect(() => {
398
+ graphLifecycleRef.current += 1;
399
+ const lifecycle = graphLifecycleRef.current;
400
+ isMountedRef.current = true;
401
+ return () => {
402
+ isMountedRef.current = false;
403
+ queueMicrotask(() => {
404
+ if (graphLifecycleRef.current !== lifecycle) return;
405
+ void stopTerrainRunner().finally(() => {
406
+ if (graphLifecycleRef.current !== lifecycle) return;
407
+ graph.dispose();
408
+ });
409
+ });
410
+ };
411
+ }, [graph, stopTerrainRunner]);
412
+ useLayoutEffect(() => {
413
+ runtime.query = null;
414
+ runtime.raycast = null;
415
+ const nextTerrainNodes = {
416
+ positionNode: null
417
+ };
418
+ terrainNodesRef.current = nextTerrainNodes;
419
+ readyRef.current = false;
420
+ setTerrainNodes(nextTerrainNodes);
421
+ setReady(false);
422
+ }, [graph, runtime]);
423
+ useTerrainParams(graph, options);
424
+ return useMemo(
425
+ () => ({
426
+ graph,
427
+ tasks: terrainTasks,
428
+ runtime,
429
+ ready,
430
+ ...terrainNodes
431
+ }),
432
+ [graph, ready, runtime, terrainNodes]
433
+ );
434
+ }
435
+
436
+ function useTerrainMesh(innerTileSegments, maxNodes) {
437
+ const [mesh] = useState(
438
+ () => new TerrainMesh({
439
+ innerTileSegments: innerTileSegments ?? 13,
440
+ maxNodes: maxNodes ?? 1024
441
+ })
442
+ );
443
+ useEffect(() => {
444
+ mesh.innerTileSegments = innerTileSegments ?? 13;
445
+ }, [mesh, innerTileSegments]);
446
+ useEffect(() => {
447
+ mesh.maxNodes = maxNodes ?? 1024;
448
+ }, [mesh, maxNodes]);
449
+ useEffect(() => {
450
+ return () => {
451
+ mesh.geometry.dispose();
452
+ };
453
+ }, [mesh]);
454
+ return mesh;
455
+ }
456
+ function syncTerrainMesh(mesh, terrain) {
457
+ const leaves = terrain.graph.peek(terrainTasks.quadtreeUpdate);
458
+ if (leaves && mesh.count !== leaves.count) {
459
+ mesh.count = leaves.count;
460
+ mesh.instanceMatrix.needsUpdate = true;
461
+ }
462
+ const raycast = terrain.runtime.raycast;
463
+ if (mesh.terrainRaycast !== raycast) {
464
+ mesh.terrainRaycast = raycast;
465
+ }
466
+ }
467
+ function attachTerrainMaterial(node, terrainNodes) {
468
+ if (!isValidElement(node)) return node;
469
+ const nextKey = `terrain-material-${terrainNodes.positionNode?.id ?? "null"}`;
470
+ if ("attach" in node.props && node.props.attach != null) {
471
+ return cloneElement(node, { key: nextKey });
472
+ }
473
+ return cloneElement(node, { attach: "material", key: nextKey });
474
+ }
475
+ function TerrainWithHandle({
476
+ terrain,
477
+ children,
478
+ innerTileSegments,
479
+ maxNodes,
480
+ ...primitiveProps
481
+ }) {
482
+ const mesh = useTerrainMesh(innerTileSegments, maxNodes);
483
+ const { visible: primitiveVisible = true, ...restPrimitiveProps } = primitiveProps;
484
+ useFrame(() => {
485
+ syncTerrainMesh(mesh, terrain);
486
+ });
487
+ return /* @__PURE__ */ jsx(TerrainProvider, { value: terrain, children: /* @__PURE__ */ jsx(
488
+ "primitive",
489
+ {
490
+ object: mesh,
491
+ visible: terrain.ready && primitiveVisible,
492
+ ...restPrimitiveProps,
493
+ children: terrain.ready ? attachTerrainMaterial(
494
+ children({
495
+ positionNode: terrain.positionNode
496
+ }),
497
+ terrain
498
+ ) : null
499
+ }
500
+ ) });
501
+ }
502
+ function InternalTerrain(props) {
503
+ const {
504
+ children,
505
+ rootSize,
506
+ origin,
507
+ maxLevel,
508
+ innerTileSegments,
509
+ skirtScale,
510
+ elevationScale,
511
+ elevation,
512
+ surface,
513
+ terrainFieldFilter,
514
+ getCameraOrigin,
515
+ cameraHysteresis,
516
+ tasks,
517
+ maxNodes,
518
+ ...primitiveProps
519
+ } = props;
520
+ const terrain = useTerrain({
521
+ rootSize,
522
+ origin,
523
+ maxLevel,
524
+ innerTileSegments,
525
+ skirtScale,
526
+ elevationScale,
527
+ elevation,
528
+ surface,
529
+ terrainFieldFilter,
530
+ getCameraOrigin,
531
+ cameraHysteresis,
532
+ tasks,
533
+ maxNodes
534
+ });
535
+ return /* @__PURE__ */ jsx(
536
+ TerrainWithHandle,
537
+ {
538
+ terrain,
539
+ innerTileSegments,
540
+ maxNodes,
541
+ ...primitiveProps,
542
+ children
543
+ }
544
+ );
545
+ }
546
+ function Terrain({
547
+ terrain: providedTerrain,
548
+ children,
549
+ rootSize,
550
+ origin,
551
+ maxLevel,
552
+ innerTileSegments,
553
+ skirtScale,
554
+ elevationScale,
555
+ elevation,
556
+ surface,
557
+ terrainFieldFilter,
558
+ getCameraOrigin,
559
+ cameraHysteresis,
560
+ tasks,
561
+ maxNodes,
562
+ ...primitiveProps
563
+ }) {
564
+ if (providedTerrain) {
565
+ return /* @__PURE__ */ jsx(
566
+ TerrainWithHandle,
567
+ {
568
+ terrain: providedTerrain,
569
+ innerTileSegments,
570
+ maxNodes,
571
+ ...primitiveProps,
572
+ children
573
+ }
574
+ );
575
+ }
576
+ return /* @__PURE__ */ jsx(
577
+ InternalTerrain,
578
+ {
579
+ rootSize,
580
+ origin,
581
+ maxLevel,
582
+ innerTileSegments,
583
+ skirtScale,
584
+ elevationScale,
585
+ elevation,
586
+ surface,
587
+ terrainFieldFilter,
588
+ getCameraOrigin,
589
+ cameraHysteresis,
590
+ tasks,
591
+ maxNodes,
592
+ ...primitiveProps,
593
+ children
594
+ }
595
+ );
596
+ }
597
+
598
+ export { Terrain, TerrainProvider, useTerrain, useTerrainContext };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@hello-terrain/react",
3
+ "description": "High performance terrain system for three.js and react-three/fiber",
4
+ "homepage": "http://hello-terrain.kenny.wtf",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/kenjinp/hello-terrain.git"
8
+ },
9
+ "version": "0.0.0-alpha.10",
10
+ "type": "module",
11
+ "main": "./dist/index.mjs",
12
+ "module": "./dist/index.mjs",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.mjs",
18
+ "require": "./dist/index.cjs"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "dependencies": {
25
+ "@hello-terrain/three": "0.0.0-alpha.10",
26
+ "@hello-terrain/work": "0.3.0"
27
+ },
28
+ "devDependencies": {
29
+ "@testing-library/dom": "^10.4.0",
30
+ "@testing-library/react": "^16.2.0",
31
+ "@types/react": "^18.0.0 || ^19.0.0",
32
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
33
+ "@types/three": ">=0.182.0",
34
+ "jsdom": "^26.0.0",
35
+ "unbuild": "^3.5.0",
36
+ "vitest": "^4.0.17",
37
+ "@config/oxfmt": "0.1.0",
38
+ "@config/typescript": "0.1.0",
39
+ "@config/oxlint": "0.1.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@react-three/fiber": "^8.0.0 || ^9.0.0",
43
+ "react": "^18.0.0 || ^19.0.0",
44
+ "react-dom": "^18.0.0 || ^19.0.0",
45
+ "three": ">=0.182.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "unbuild",
52
+ "release": "pnpm run build && pnpm publish --access=public",
53
+ "test": "vitest",
54
+ "lint": "oxlint -c node_modules/@config/oxlint/react.json",
55
+ "format": "oxfmt -c node_modules/@config/oxfmt/base.json --write ."
56
+ }
57
+ }