From 07e571bb238828e07c498500a5916ac149c55e5a Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Fri, 4 Sep 2026 17:05:02 +0800 Subject: [PATCH 01/13] ci: add pull request quality workflow - Run typecheck, lint, test and build on PR and pushes to main/develop - Add lint:ci script without --fix so CI never rewrites sources - Ignore docs directory in ESLint config --- .github/workflows/ci.yml | 43 ++++++++++++++++++++++++++++++++++++++++ eslint.config.js | 2 +- package.json | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..486447a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: CI + +on: + pull_request: + branches: + - main + - develop + push: + branches: + - main + - develop + +jobs: + quality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: 24 + + - name: Install Pnpm + run: npm install -g pnpm + + - name: Install Dependencies + run: pnpm i + + - name: Type Check + run: pnpm typecheck + + - name: Lint + run: pnpm lint:ci + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 0478bb7..eee05ce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -47,6 +47,6 @@ export default [ }, }, { - ignores: ['dist', '.eslintrc.cjs'], + ignores: ['dist', '.eslintrc.cjs', 'docs'], }, ]; diff --git a/package.json b/package.json index 9bb4c05..eb6fa2f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "test:unit": "vitest", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint . --fix --ignore-pattern .gitignore", + "lint:ci": "eslint . --ignore-pattern .gitignore", "format": "prettier --write src/", "release": "node scripts/release.js", "release:patch": "node scripts/release.js patch", From d19f91dc89c81523fe12d2fcd81b51f770603a34 Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Fri, 4 Sep 2026 17:05:19 +0800 Subject: [PATCH 02/13] feat(picker): add raycaster based picking - Add Picker class with click and hover detection via Raycaster - Support targets, recursive, filter, layers, near and far options - Coalesce hover raycasts through requestAnimationFrame - Skip raycasting entirely when no listener is registered - Use a click threshold to tell clicks apart from camera drags - Add onClick, onHover, pickFilter and pickRecursive props to Scene - Expose Picker plus PickEvent, PickEventType, PickCallback and PickerOptions from the public entry point - Add unit tests and English and Chinese documentation --- docs/guide/picking.md | 158 ++++++ .../current.json | 4 + .../current/picking.md | 154 ++++++ docs/sidebars.js | 1 + docs/src/components/Picking.tsx | 69 +++ src/__tests__/Picker.test.ts | 523 ++++++++++++++++++ src/__tests__/api.test.ts | 2 + src/components/Scene.tsx | 56 ++ src/context/SceneContext.tsx | 1 + src/index.ts | 5 +- src/utils/Picker.ts | 270 +++++++++ 11 files changed, 1242 insertions(+), 1 deletion(-) create mode 100644 docs/guide/picking.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md create mode 100644 docs/src/components/Picking.tsx create mode 100644 src/__tests__/Picker.test.ts create mode 100644 src/utils/Picker.ts diff --git a/docs/guide/picking.md b/docs/guide/picking.md new file mode 100644 index 0000000..01e78f4 --- /dev/null +++ b/docs/guide/picking.md @@ -0,0 +1,158 @@ +*** + +id: picking +lang: en-US +title: Picking +-------------- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import Picking from '@site/src/components/Picking' + +## Type + +Component (Scene props) / Class (standalone) + +## Default Usage + + + + + + + + + + +```tsx +import { useRef } from 'react' +import { Scene } from 'react-three-lite' +import type { SceneComponents, PickEvent } from 'react-three-lite' +import * as THREE from 'three' + +function App({ rendererType }: { rendererType?: 'webgpu' | 'webgl' }) { + const hoveredRef = useRef(null) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + camera.position.set(0, 1, 6) + camera.lookAt(0, 0, 0) + + const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8) + const material = new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }) + const mesh = new THREE.Mesh(geometry, material) + scene.add(mesh) + } + + const handleClick = (event: PickEvent | null) => { + if (!event) return + const material = (event.object as THREE.Mesh).material as THREE.MeshStandardMaterial + material.color.setHex(Math.random() * 0xffffff) + } + + // `onHover` only fires when the object under the pointer changes, so keep a + // reference to the previously hovered mesh in order to restore it. + const handleHover = (event: PickEvent | null) => { + const previous = hoveredRef.current + if (previous) { + const material = previous.material as THREE.MeshStandardMaterial + material.emissive.setHex(0x000000) + } + + const mesh = event ? (event.object as THREE.Mesh) : null + if (mesh) { + const material = mesh.material as THREE.MeshStandardMaterial + material.emissive.setHex(0x444444) + } + hoveredRef.current = mesh + } + + return ( + + ) +} +``` + +## Scene Props + +| Name | Type | Default | Description | +| ------------- | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------- | +| onClick | `(event: PickEvent \| null) => void` | — | Fired on a click (pointer down + up within the threshold) | +| onHover | `(event: PickEvent \| null) => void` | — | Fired when the object under the pointer changes, or `null` when the pointer leaves the canvas | +| pickFilter | `(object: Object3D) => boolean` | — | Optional predicate to filter which objects are pickable | +| pickRecursive | `boolean` | `true` | Traverse children of the scene when picking | + +## Picker Class + +For advanced use cases, the `Picker` class can be imported and used directly. + +```tsx +import { Picker } from 'react-three-lite' +import type { PickEvent, PickerOptions } from 'react-three-lite' +import * as THREE from 'three' + +const picker = new Picker(scene, camera, renderer.domElement, { + recursive: true, + filter: (object) => object.name !== 'non-pickable', + clickThreshold: 5, +}) + +picker.on('click', (event: PickEvent | null) => { + if (event) console.log('clicked', event.object.name) +}) + +picker.on('hover', (event: PickEvent | null) => { + if (event) console.log('hovered', event.object.name) +}) + +// Cleanup +picker.dispose() +``` + +## PickerOptions + +| Property | Type | Default | Description | +| -------------- | ------------------------------- | ------- | --------------------------------------------------------------- | +| targets | `Object3D[]` | — | Objects to test against, defaults to the whole scene | +| recursive | `boolean` | `true` | Traverse descendants of the targets | +| filter | `(object: Object3D) => boolean` | — | Keep only intersections that pass this predicate | +| enableHover | `boolean` | `true` | Emit hover events on pointer move | +| clickThreshold | `number` | `5` | Max pointer travel (px) between down and up to count as a click | +| layers | `number \| number[]` | — | Camera layers to pick from, default: all layers | +| near | `number` | — | Raycaster near plane | +| far | `number` | — | Raycaster far plane | + +## PickEvent + +| Property | Type | Description | +| ------------- | --------------------------- | ------------------------------------------------------------ | +| object | `Object3D` | The intersected object | +| point | `Vector3` | Intersection point in world space | +| distance | `number` | Distance from the camera to the intersection point | +| intersection | `Intersection` | The closest intersection that passed the filter | +| intersections | `Intersection[]` | All intersections that passed the filter, sorted by distance | +| pointer | `Vector2` | Pointer position in normalized device coordinates | +| nativeEvent | `PointerEvent \| undefined` | The originating pointer event, absent for programmatic picks | + +## Picker Methods + +| Name | Parameters | Description | +| ----------- | --------------------------------------------------------- | ------------------------------------------------------ | +| constructor | `(scene, camera, domElement, options?)` | Create a new Picker instance | +| on | `(type: 'click' \| 'hover', listener) => () => void` | Subscribe to an event, returns an unsubscribe function | +| off | `(type: 'click' \| 'hover', listener) => void` | Unsubscribe from an event | +| pickAt | `(clientX: number, clientY: number) => PickEvent \| null` | Programmatically pick at a client position | +| setTargets | `(targets?: Object3D[]) => void` | Replace the objects to test against | +| setFilter | `(filter?: (object) => boolean) => void` | Replace the intersection filter | +| setEnabled | `(enabled: boolean) => void` | Enable or disable the picker | +| getHovered | `() => Object3D \| null` | The object currently under the pointer | +| dispose | `() => void` | Detach listeners and release resources | + diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json index c1e4cd6..ff8fdf0 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json @@ -55,6 +55,10 @@ "message": "模型旋转器", "description": "The label for doc 'model-rotator' in sidebar 'guideSidebar'" }, + "sidebar.guideSidebar.doc.picking": { + "message": "拾取", + "description": "The label for doc 'picking' in sidebar 'guideSidebar'" + }, "sidebar.guideSidebar.doc.meshes/wave-circle-mesh": { "message": "波浪圆环网格", "description": "The label for doc 'meshes/wave-circle-mesh' in sidebar 'guideSidebar'" diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md new file mode 100644 index 0000000..c72aa0a --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md @@ -0,0 +1,154 @@ +--- +lang: zh-CN +title: 拾取 +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import Picking from '@site/src/components/Picking' + +## 类型 + +组件(Scene 属性)/ 类(独立使用) + +## 默认用法 + + + + + + + + + + +```tsx +import { useRef } from 'react' +import { Scene } from 'react-three-lite' +import type { SceneComponents, PickEvent } from 'react-three-lite' +import * as THREE from 'three' + +function App({ rendererType }: { rendererType?: 'webgpu' | 'webgl' }) { + const hoveredRef = useRef(null) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + camera.position.set(0, 1, 6) + camera.lookAt(0, 0, 0) + + const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8) + const material = new THREE.MeshStandardMaterial({ color: 0x4fc3f7 }) + const mesh = new THREE.Mesh(geometry, material) + scene.add(mesh) + } + + const handleClick = (event: PickEvent | null) => { + if (!event) return + const material = (event.object as THREE.Mesh).material as THREE.MeshStandardMaterial + material.color.setHex(Math.random() * 0xffffff) + } + + // onHover 仅在指针下的对象发生变化时触发,因此需要记录上一个悬停对象以便还原。 + const handleHover = (event: PickEvent | null) => { + const previous = hoveredRef.current + if (previous) { + const material = previous.material as THREE.MeshStandardMaterial + material.emissive.setHex(0x000000) + } + + const mesh = event ? (event.object as THREE.Mesh) : null + if (mesh) { + const material = mesh.material as THREE.MeshStandardMaterial + material.emissive.setHex(0x444444) + } + hoveredRef.current = mesh + } + + return ( + + ) +} +``` + +## Scene 属性 + +| 名称 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| onClick | `(event: PickEvent \| null) => void` | — | 点击事件(按下 + 抬起,且移动距离在阈值内) | +| onHover | `(event: PickEvent \| null) => void` | — | 悬停对象变化时触发,指针离开画布时传入 `null` | +| pickFilter | `(object: Object3D) => boolean` | — | 可选过滤函数,决定哪些对象可被拾取 | +| pickRecursive | `boolean` | `true` | 拾取时遍历场景子对象 | + +## Picker 类 + +对于高级用法,可以直接导入 `Picker` 类: + +```tsx +import { Picker } from 'react-three-lite' +import type { PickEvent, PickerOptions } from 'react-three-lite' +import * as THREE from 'three' + +const picker = new Picker(scene, camera, renderer.domElement, { + recursive: true, + filter: (object) => object.name !== 'non-pickable', + clickThreshold: 5, +}) + +picker.on('click', (event: PickEvent | null) => { + if (event) console.log('clicked', event.object.name) +}) + +picker.on('hover', (event: PickEvent | null) => { + if (event) console.log('hovered', event.object.name) +}) + +// 清理资源 +picker.dispose() +``` + +## PickerOptions + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| targets | `Object3D[]` | — | 测试对象列表,默认为整个场景 | +| recursive | `boolean` | `true` | 遍历目标的后代 | +| filter | `(object: Object3D) => boolean` | — | 仅保留通过该谓词的相交结果 | +| enableHover | `boolean` | `true` | 在指针移动时触发悬停事件 | +| clickThreshold | `number` | `5` | 按下和抬起之间允许的最大指针移动距离(像素) | +| layers | `number \| number[]` | — | 拾取时使用的相机层,默认使用所有层 | +| near | `number` | — | 射线检测近平面 | +| far | `number` | — | 射线检测远平面 | + +## PickEvent + +| 属性 | 类型 | 描述 | +|------|------|------| +| object | `Object3D` | 被相交的对象 | +| point | `Vector3` | 世界空间中的相交点 | +| distance | `number` | 从相机到相交点的距离 | +| intersection | `Intersection` | 通过过滤的最接近的相交结果 | +| intersections | `Intersection[]` | 所有通过过滤的相交结果,按距离排序 | +| pointer | `Vector2` | 归一化设备坐标中的指针位置 | +| nativeEvent | `PointerEvent \| undefined` | 原始指针事件,程序化拾取时为 `undefined` | + +## Picker 方法 + +| 名称 | 参数 | 描述 | +|------|------|------| +| constructor | `(scene, camera, domElement, options?)` | 创建新的 Picker 实例 | +| on | `(type: 'click' \| 'hover', listener) => () => void` | 订阅事件,返回取消订阅函数 | +| off | `(type: 'click' \| 'hover', listener) => void` | 取消订阅事件 | +| pickAt | `(clientX: number, clientY: number) => PickEvent \| null` | 在指定客户端位置程序化拾取 | +| setTargets | `(targets?: Object3D[]) => void` | 替换测试对象列表 | +| setFilter | `(filter?: (object) => boolean) => void` | 替换相交过滤器 | +| setEnabled | `(enabled: boolean) => void` | 启用或禁用拾取器 | +| getHovered | `() => Object3D \| null` | 当前指针下的对象 | +| dispose | `() => void` | 移除事件监听并释放资源 | \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index da22c17..c241878 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -16,6 +16,7 @@ const sidebars = { 'movable-element', 'animation', 'model-rotator', + 'picking', { type: 'category', label: 'Mesh', diff --git a/docs/src/components/Picking.tsx b/docs/src/components/Picking.tsx new file mode 100644 index 0000000..adcc7ae --- /dev/null +++ b/docs/src/components/Picking.tsx @@ -0,0 +1,69 @@ +import { useRef } from 'react' +import * as THREE from 'three' +import { Scene } from 'react-three-lite' +import type { SceneComponents, PickEvent } from 'react-three-lite' + +interface PickingProps { + rendererType?: 'webgpu' | 'webgl' +} + +const COLORS = [0x4fc3f7, 0x81c784, 0xffb74d, 0xe57373, 0xba68c8, 0x4db6ac] +const POSITIONS: [number, number, number][] = [ + [-3, 0, 0], + [-1.8, 0, 0], + [-0.6, 0, 0], + [0.6, 0, 0], + [1.8, 0, 0], + [3, 0, 0], +] + +export default function PickingComponent({ rendererType }: PickingProps) { + const hoveredRef = useRef(null) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + camera.position.set(0, 1, 6) + camera.lookAt(0, 0, 0) + + POSITIONS.forEach((pos, i) => { + const geometry = new THREE.BoxGeometry(0.8, 0.8, 0.8) + const material = new THREE.MeshStandardMaterial({ color: COLORS[i] }) + const mesh = new THREE.Mesh(geometry, material) + mesh.position.set(...pos) + scene.add(mesh) + }) + } + + const handleClick = (event: PickEvent | null) => { + if (!event) return + const material = (event.object as THREE.Mesh).material as THREE.MeshStandardMaterial + material.color.setHex(Math.random() * 0xffffff) + } + + const handleHover = (event: PickEvent | null) => { + const previous = hoveredRef.current + if (previous) { + const material = previous.material as THREE.MeshStandardMaterial + material.emissive.setHex(0x000000) + } + + const mesh = event ? (event.object as THREE.Mesh) : null + if (mesh) { + const material = mesh.material as THREE.MeshStandardMaterial + material.emissive.setHex(0x444444) + } + hoveredRef.current = mesh + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/__tests__/Picker.test.ts b/src/__tests__/Picker.test.ts new file mode 100644 index 0000000..351af44 --- /dev/null +++ b/src/__tests__/Picker.test.ts @@ -0,0 +1,523 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Picker from '../utils/Picker' +import { installFakeRaf, type FakeRaf } from './helpers/fakeRaf' + +interface PickerInternals { + raycaster: THREE.Raycaster + recursive: boolean + enableHover: boolean + clickThreshold: number + enabled: boolean + hovered: THREE.Object3D | null + hoverFrameId: number | null + listeners: Record<'click' | 'hover', Set> +} + +function internals(picker: Picker): PickerInternals { + return picker as unknown as PickerInternals +} + +// jsdom lays every element out at 0x0, so the NDC conversion would divide by the +// fallback of 1 and place every pointer far outside the frustum. A fixed +// 100x100 box makes the maths predictable: (50, 50) is the centre of the canvas. +const RECT_SIZE = 100 + +function stubRect(element: HTMLElement) { + element.getBoundingClientRect = () => + ({ + left: 0, + top: 0, + right: RECT_SIZE, + bottom: RECT_SIZE, + width: RECT_SIZE, + height: RECT_SIZE, + x: 0, + y: 0, + toJSON: () => ({}) + }) as DOMRect +} + +// jsdom does not implement PointerEvent, but the listeners only read clientX / +// clientY, which MouseEvent provides. +function pointerEvent(type: string, clientX: number, clientY: number) { + return new MouseEvent(type, { clientX, clientY, bubbles: true }) +} + +describe('Picker', () => { + let raf: FakeRaf + let scene: THREE.Scene + let camera: THREE.PerspectiveCamera + let domElement: HTMLDivElement + let mesh: THREE.Mesh + + beforeEach(() => { + raf = installFakeRaf() + + scene = new THREE.Scene() + + camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000) + camera.position.set(0, 0, 5) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld(true) + camera.updateProjectionMatrix() + + mesh = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshBasicMaterial()) + mesh.updateMatrixWorld(true) + scene.add(mesh) + + domElement = document.createElement('div') + stubRect(domElement) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + describe('constructor', () => { + it('attaches the four pointer listeners to the canvas', () => { + const addEventListener = vi.spyOn(domElement, 'addEventListener') + + const picker = new Picker(scene, camera, domElement) + + expect(addEventListener.mock.calls.map((call) => call[0])).toEqual([ + 'pointerdown', + 'pointerup', + 'pointermove', + 'pointerleave' + ]) + + picker.dispose() + }) + + it('picks recursively with hover enabled and a 5px click threshold by default', () => { + const picker = new Picker(scene, camera, domElement) + + const state = internals(picker) + expect(state.recursive).toBe(true) + expect(state.enableHover).toBe(true) + expect(state.clickThreshold).toBe(5) + expect(state.enabled).toBe(true) + + picker.dispose() + }) + + it('forwards the near and far planes to the raycaster', () => { + const picker = new Picker(scene, camera, domElement, { near: 1, far: 20 }) + + expect(internals(picker).raycaster.near).toBe(1) + expect(internals(picker).raycaster.far).toBe(20) + + picker.dispose() + }) + }) + + describe('pickAt', () => { + it('returns the closest hit with its world space point', () => { + const picker = new Picker(scene, camera, domElement) + + const event = picker.pickAt(50, 50) + + expect(event).not.toBeNull() + expect(event?.object).toBe(mesh) + // The camera looks down -z, so the front face of the box is hit first. + expect(event?.point.z).toBeCloseTo(1) + expect(event?.distance).toBeCloseTo(4) + // The centre of the canvas shoots straight down the shared diagonal of the + // two front face triangles, so the exact count is a geometry detail. What + // matters is that `intersection` is the closest of the list. + expect(event?.intersections[0]).toBe(event?.intersection) + expect(event?.nativeEvent).toBeUndefined() + + picker.dispose() + }) + + it('reports the pointer in normalized device coordinates', () => { + const picker = new Picker(scene, camera, domElement) + + expect(picker.pickAt(50, 50)?.pointer.toArray()).toEqual([0, 0]) + + picker.dispose() + }) + + it('returns null when the ray misses everything', () => { + const picker = new Picker(scene, camera, domElement) + + // The top right corner of the frustum passes well outside the box. + expect(picker.pickAt(RECT_SIZE, 0)).toBeNull() + + picker.dispose() + }) + + it('returns null for an empty scene', () => { + scene.remove(mesh) + const picker = new Picker(scene, camera, domElement) + + expect(picker.pickAt(50, 50)).toBeNull() + + picker.dispose() + }) + }) + + describe('filter', () => { + it('drops the intersections whose object fails the predicate', () => { + const picker = new Picker(scene, camera, domElement, { + filter: (object) => object.name === 'pickable' + }) + + expect(picker.pickAt(50, 50)).toBeNull() + + mesh.name = 'pickable' + expect(picker.pickAt(50, 50)?.object).toBe(mesh) + + picker.dispose() + }) + + it('accepts every object again once the filter is removed', () => { + const picker = new Picker(scene, camera, domElement, { filter: () => false }) + + expect(picker.pickAt(50, 50)).toBeNull() + + picker.setFilter() + expect(picker.pickAt(50, 50)?.object).toBe(mesh) + + picker.dispose() + }) + }) + + describe('targets', () => { + it('only tests the objects it was given', () => { + const other = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), new THREE.MeshBasicMaterial()) + other.updateMatrixWorld(true) + + const picker = new Picker(scene, camera, domElement, { targets: [other] }) + + expect(picker.pickAt(50, 50)?.object).toBe(other) + + picker.setTargets([mesh]) + expect(picker.pickAt(50, 50)?.object).toBe(mesh) + + // Passing undefined falls back to the whole scene. + picker.setTargets() + expect(picker.pickAt(50, 50)?.object).toBe(mesh) + + picker.dispose() + }) + }) + + describe('layers', () => { + it('ignores the objects that are not on an enabled layer', () => { + const picker = new Picker(scene, camera, domElement, { layers: 1 }) + + expect(picker.pickAt(50, 50)).toBeNull() + + mesh.layers.set(1) + expect(picker.pickAt(50, 50)?.object).toBe(mesh) + + picker.dispose() + }) + }) + + describe('click', () => { + it('emits on a pointer down and up that stayed within the threshold', () => { + const picker = new Picker(scene, camera, domElement) + const onClick = vi.fn() + picker.on('click', onClick) + + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 52, 51)) + + expect(onClick).toHaveBeenCalledTimes(1) + expect(onClick.mock.calls[0][0].object).toBe(mesh) + + picker.dispose() + }) + + it('emits null when the click landed on nothing', () => { + const picker = new Picker(scene, camera, domElement) + const onClick = vi.fn() + picker.on('click', onClick) + + domElement.dispatchEvent(pointerEvent('pointerdown', RECT_SIZE, 0)) + domElement.dispatchEvent(pointerEvent('pointerup', RECT_SIZE, 0)) + + expect(onClick).toHaveBeenCalledWith(null) + + picker.dispose() + }) + + it('treats a drag as a camera move rather than a click', () => { + const picker = new Picker(scene, camera, domElement) + const onClick = vi.fn() + picker.on('click', onClick) + + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 70, 50)) + + expect(onClick).not.toHaveBeenCalled() + + picker.dispose() + }) + + it('honours a custom threshold', () => { + const picker = new Picker(scene, camera, domElement, { clickThreshold: 40 }) + const onClick = vi.fn() + picker.on('click', onClick) + + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 70, 50)) + + expect(onClick).toHaveBeenCalledTimes(1) + + picker.dispose() + }) + + it('ignores a pointer up that was not preceded by a pointer down', () => { + const picker = new Picker(scene, camera, domElement) + const onClick = vi.fn() + picker.on('click', onClick) + + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(onClick).not.toHaveBeenCalled() + + picker.dispose() + }) + + it('does not raycast while nobody is listening', () => { + const picker = new Picker(scene, camera, domElement) + const intersectObjects = vi.spyOn(internals(picker).raycaster, 'intersectObjects') + + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(intersectObjects).not.toHaveBeenCalled() + + picker.dispose() + }) + }) + + describe('hover', () => { + it('coalesces a burst of moves into a single raycast per frame', () => { + const picker = new Picker(scene, camera, domElement) + picker.on('hover', vi.fn()) + const intersectObjects = vi.spyOn(internals(picker).raycaster, 'intersectObjects') + + domElement.dispatchEvent(pointerEvent('pointermove', 40, 40)) + domElement.dispatchEvent(pointerEvent('pointermove', 45, 45)) + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + + expect(raf.request).toHaveBeenCalledTimes(1) + expect(intersectObjects).not.toHaveBeenCalled() + + raf.flush() + + expect(intersectObjects).toHaveBeenCalledTimes(1) + + picker.dispose() + }) + + it('emits only when the object under the pointer changes', () => { + const picker = new Picker(scene, camera, domElement) + const onHover = vi.fn() + picker.on('hover', onHover) + + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + raf.flush() + + expect(onHover).toHaveBeenCalledTimes(1) + expect(onHover.mock.calls[0][0].object).toBe(mesh) + expect(picker.getHovered()).toBe(mesh) + + // Still over the same mesh, so there is nothing new to report. + domElement.dispatchEvent(pointerEvent('pointermove', 52, 52)) + raf.flush() + + expect(onHover).toHaveBeenCalledTimes(1) + + // Moving off the mesh reports the empty space once. + domElement.dispatchEvent(pointerEvent('pointermove', RECT_SIZE, 0)) + raf.flush() + + expect(onHover).toHaveBeenCalledTimes(2) + expect(onHover.mock.calls[1][0]).toBeNull() + expect(picker.getHovered()).toBeNull() + + picker.dispose() + }) + + it('does not schedule a frame while nobody is listening', () => { + const picker = new Picker(scene, camera, domElement) + + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + + expect(raf.request).not.toHaveBeenCalled() + + picker.dispose() + }) + + it('stays silent when hover is disabled', () => { + const picker = new Picker(scene, camera, domElement, { enableHover: false }) + const onHover = vi.fn() + picker.on('hover', onHover) + + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + raf.flush() + + expect(onHover).not.toHaveBeenCalled() + + picker.dispose() + }) + + it('reports null when the pointer leaves the canvas', () => { + const picker = new Picker(scene, camera, domElement) + const onHover = vi.fn() + picker.on('hover', onHover) + + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + raf.flush() + onHover.mockClear() + + domElement.dispatchEvent(pointerEvent('pointerleave', 50, 50)) + + expect(onHover).toHaveBeenCalledWith(null) + expect(picker.getHovered()).toBeNull() + + picker.dispose() + }) + + it('does not report a leave when nothing was hovered', () => { + const picker = new Picker(scene, camera, domElement) + const onHover = vi.fn() + picker.on('hover', onHover) + + domElement.dispatchEvent(pointerEvent('pointerleave', 50, 50)) + + expect(onHover).not.toHaveBeenCalled() + + picker.dispose() + }) + }) + + describe('setEnabled', () => { + it('stops reacting to pointer events and clears the hover state', () => { + const picker = new Picker(scene, camera, domElement) + const onClick = vi.fn() + const onHover = vi.fn() + picker.on('click', onClick) + picker.on('hover', onHover) + + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + raf.flush() + onHover.mockClear() + + picker.setEnabled(false) + + expect(onHover).toHaveBeenCalledWith(null) + expect(picker.getHovered()).toBeNull() + + onHover.mockClear() + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + raf.flush() + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(onHover).not.toHaveBeenCalled() + expect(onClick).not.toHaveBeenCalled() + + picker.setEnabled(true) + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(onClick).toHaveBeenCalledTimes(1) + + picker.dispose() + }) + }) + + describe('on / off', () => { + it('returns an unsubscribe function', () => { + const picker = new Picker(scene, camera, domElement) + const onClick = vi.fn() + const unsubscribe = picker.on('click', onClick) + + unsubscribe() + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(onClick).not.toHaveBeenCalled() + + picker.dispose() + }) + + it('notifies every listener of the same event', () => { + const picker = new Picker(scene, camera, domElement) + const first = vi.fn() + const second = vi.fn() + picker.on('click', first) + picker.on('click', second) + + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + + picker.off('click', first) + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(2) + + picker.dispose() + }) + }) + + describe('dispose', () => { + it('detaches the listeners and clears the subscriptions', () => { + const picker = new Picker(scene, camera, domElement) + const removeEventListener = vi.spyOn(domElement, 'removeEventListener') + const onClick = vi.fn() + picker.on('click', onClick) + + picker.dispose() + + expect(removeEventListener.mock.calls.map((call) => call[0])).toEqual([ + 'pointerdown', + 'pointerup', + 'pointermove', + 'pointerleave' + ]) + expect(internals(picker).listeners.click.size).toBe(0) + expect(internals(picker).listeners.hover.size).toBe(0) + + domElement.dispatchEvent(pointerEvent('pointerdown', 50, 50)) + domElement.dispatchEvent(pointerEvent('pointerup', 50, 50)) + + expect(onClick).not.toHaveBeenCalled() + }) + + it('cancels a hover frame that has not run yet', () => { + const picker = new Picker(scene, camera, domElement) + picker.on('hover', vi.fn()) + + domElement.dispatchEvent(pointerEvent('pointermove', 50, 50)) + expect(internals(picker).hoverFrameId).not.toBeNull() + + picker.dispose() + + expect(raf.cancel).toHaveBeenCalledTimes(1) + expect(raf.pending.size).toBe(0) + expect(internals(picker).hoverFrameId).toBeNull() + }) + + it('is safe to call without any pointer activity', () => { + const picker = new Picker(scene, camera, domElement) + + expect(() => picker.dispose()).not.toThrow() + expect(raf.cancel).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index a2cb84b..ebe24c5 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -20,6 +20,7 @@ const EXPECTED_EXPORTS = [ 'Movable', 'OBJLoader', 'OBJLoaderAsync', + 'Picker', 'Popup', 'R3L', 'Rain', @@ -60,6 +61,7 @@ describe('public API surface', () => { 'Movable', 'OBJLoader', 'Popup', + 'Picker', 'Rain', 'Scene', 'SkyBox', diff --git a/src/components/Scene.tsx b/src/components/Scene.tsx index b4e3303..448d003 100644 --- a/src/components/Scene.tsx +++ b/src/components/Scene.tsx @@ -8,6 +8,7 @@ import LightUtil from '../utils/Light' import Renderer, { RendererType } from '../utils/Renderer' import AxesHelperUtil from '../utils/AxesHelper' import Controls from '../utils/Controls' +import Picker, { PickCallback } from '../utils/Picker' import { SceneContext, SceneComponents, @@ -31,6 +32,10 @@ interface SceneProps { onBeforeFrame?: CallbackFrame onFrame?: CallbackFrame onAfterFrame?: CallbackFrame + onClick?: PickCallback + onHover?: PickCallback + pickFilter?: (object: THREE.Object3D) => boolean + pickRecursive?: boolean children?: React.ReactNode style?: React.CSSProperties className?: string @@ -49,6 +54,10 @@ const SceneComponent = ({ onCreated, onBeforeFrame, onAfterFrame, + onClick, + onHover, + pickFilter, + pickRecursive = true, children, style, className @@ -60,6 +69,24 @@ const SceneComponent = ({ // Use useState instead of useRef to ensure context updates trigger re-render const [sceneSlotProps, setSceneSlotProps] = useState({}) + // Picking props are held in refs so that changing a callback does not tear + // down and rebuild the whole scene. The refs are seeded with the first render + // values and synced in an effect, because writing to a ref during render is + // not allowed. + const onClickRef = useRef(onClick) + const onHoverRef = useRef(onHover) + const pickFilterRef = useRef<((object: THREE.Object3D) => boolean) | undefined>(pickFilter) + const pickRecursiveRef = useRef(pickRecursive) + + useEffect(() => { + onClickRef.current = onClick + onHoverRef.current = onHover + pickFilterRef.current = pickFilter + pickRecursiveRef.current = pickRecursive + }, [onClick, onHover, pickFilter, pickRecursive]) + + const pickerRef = useRef(null) + const beforeFrameChildrenRef = useRef([]) const afterFrameChildrenRef = useRef([]) const frameCallbackSetRef = useRef(false) @@ -190,12 +217,21 @@ const SceneComponent = ({ scene.background = new THREE.Color(bgColor) } + // The renderer canvas is attached by createScene, so the picker can now + // bind its pointer listeners to it. + const picker = new Picker(scene, camera, currentRenderer.domElement, { + recursive: pickRecursiveRef.current, + filter: (object) => (pickFilterRef.current ? pickFilterRef.current(object) : true) + }) + pickerRef.current = picker + // Update context value with useState to trigger child component re-render setSceneSlotProps({ container: container, renderer: currentRenderer, scene: scene, sceneComponents: sceneComponents, + picker: picker, setFrame: (callback: CallbackFrame) => { callbackFrameRef.current = callback frameCallbackSetRef.current = true // Mark custom frame callback as set @@ -238,6 +274,9 @@ const SceneComponent = ({ frameCallbackSetRef.current = false beforeFrameSetRef.current = false + pickerRef.current?.dispose() + pickerRef.current = null + if (disposeSceneFn) { disposeSceneFn() disposeSceneFn = null @@ -279,6 +318,23 @@ const SceneComponent = ({ } }, [rendererType]) + // Only subscribe when a handler is actually provided, so an unused picker + // never runs a raycast. The deps track whether a handler exists rather than + // its identity, so swapping the callback does not resubscribe. + const picker = sceneSlotProps.picker + const hasClickHandler = Boolean(onClick) + const hasHoverHandler = Boolean(onHover) + + useEffect(() => { + if (!picker || !hasClickHandler) return + return picker.on('click', (event) => onClickRef.current?.(event)) + }, [picker, hasClickHandler]) + + useEffect(() => { + if (!picker || !hasHoverHandler) return + return picker.on('hover', (event) => onHoverRef.current?.(event)) + }, [picker, hasHoverHandler]) + return (
void addBeforeFrame?: (callback: CallbackFrame) => () => void addAfterFrame?: (callback: CallbackFrame) => () => void diff --git a/src/index.ts b/src/index.ts index f5186ac..41189a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,9 +23,11 @@ import { import SweepLight from './utils/SweepLight' import ModelRotator from './utils/ModelRotator' +import Picker from './utils/Picker' import { LightGradient, LightGradientOptions } from './utils/Light' import type { CalloutOptions, LineShape, BendAxis, LabelAnchor } from './utils/Callout' +import type { PickEvent, PickEventType, PickCallback, PickerOptions } from './utils/Picker' // components export { Scene, GLTFLoader, FBXLoader, OBJLoader, Bloom, Rain, Snow, SceneContext, useScene } @@ -34,9 +36,10 @@ export { Scene, GLTFLoader, FBXLoader, OBJLoader, Bloom, Rain, Snow, SceneContex export type { SceneComponents, CallbackFrame, R3LRenderer } from './context/SceneContext' export type { RendererType } from './utils/Renderer' export type { LightGradientOptions, CalloutOptions, LineShape, BendAxis, LabelAnchor } +export type { PickEvent, PickEventType, PickCallback, PickerOptions } // class -export { SkyBox, Popup, Callout, Movable, WaveCircleMesh, FlowLineMesh, Animation, SweepLight, LightGradient, ModelRotator } +export { SkyBox, Popup, Callout, Movable, WaveCircleMesh, FlowLineMesh, Animation, SweepLight, LightGradient, ModelRotator, Picker } // function - async loaders (no hooks) export { GLTFLoaderFn as GLTFLoaderAsync, FBXLoaderFn as FBXLoaderAsync, OBJLoaderFn as OBJLoaderAsync } diff --git a/src/utils/Picker.ts b/src/utils/Picker.ts new file mode 100644 index 0000000..c74f025 --- /dev/null +++ b/src/utils/Picker.ts @@ -0,0 +1,270 @@ +import * as THREE from 'three' + +export interface PickEvent { + /** The intersected object */ + object: THREE.Object3D + /** Intersection point in world space */ + point: THREE.Vector3 + /** Distance from the camera to the intersection point */ + distance: number + /** The closest intersection that passed the filter */ + intersection: THREE.Intersection + /** All intersections that passed the filter, sorted by distance */ + intersections: THREE.Intersection[] + /** Pointer position in normalized device coordinates (-1 to 1) */ + pointer: THREE.Vector2 + /** The originating pointer event, absent for programmatic picks */ + nativeEvent?: PointerEvent +} + +export type PickEventType = 'click' | 'hover' + +/** Receives the pick result, or `null` when nothing was hit */ +export type PickCallback = (event: PickEvent | null) => void + +export interface PickerOptions { + /** Objects to test against, default: the whole scene */ + targets?: THREE.Object3D[] + /** Traverse descendants of the targets, default: true */ + recursive?: boolean + /** Keep only the intersections whose object passes this predicate */ + filter?: (object: THREE.Object3D) => boolean + /** Emit hover events on pointer move, default: true */ + enableHover?: boolean + /** Max pointer travel in pixels between down and up to still count as a click, default: 5 */ + clickThreshold?: number + /** Camera layers to pick from, default: all layers */ + layers?: number | number[] + /** Raycaster near plane */ + near?: number + /** Raycaster far plane */ + far?: number +} + +class Picker { + private scene: THREE.Scene + private camera: THREE.Camera + private domElement: HTMLElement + private raycaster: THREE.Raycaster + private pointer: THREE.Vector2 + private targets: THREE.Object3D[] | undefined + private recursive: boolean + private filter: ((object: THREE.Object3D) => boolean) | undefined + private enableHover: boolean + private clickThreshold: number + private enabled: boolean = true + private hovered: THREE.Object3D | null = null + private pointerDownX: number = 0 + private pointerDownY: number = 0 + private pointerDownValid: boolean = false + private hoverFrameId: number | null = null + private pendingMove: PointerEvent | null = null + private listeners: Record> = { + click: new Set(), + hover: new Set(), + } + + constructor( + scene: THREE.Scene, + camera: THREE.Camera, + domElement: HTMLElement, + options: PickerOptions = {}, + ) { + const { + targets, + recursive = true, + filter, + enableHover = true, + clickThreshold = 5, + layers, + near, + far, + } = options + + this.scene = scene + this.camera = camera + this.domElement = domElement + this.targets = targets + this.recursive = recursive + this.filter = filter + this.enableHover = enableHover + this.clickThreshold = clickThreshold + + this.raycaster = new THREE.Raycaster() + this.pointer = new THREE.Vector2() + + if (near !== undefined) this.raycaster.near = near + if (far !== undefined) this.raycaster.far = far + + if (layers !== undefined) { + this.raycaster.layers.disableAll() + const list = Array.isArray(layers) ? layers : [layers] + list.forEach((layer) => this.raycaster.layers.enable(layer)) + } + + this.domElement.addEventListener('pointerdown', this.onPointerDown) + this.domElement.addEventListener('pointerup', this.onPointerUp) + this.domElement.addEventListener('pointermove', this.onPointerMove) + this.domElement.addEventListener('pointerleave', this.onPointerLeave) + } + + /** Convert a client position to normalized device coordinates */ + private updatePointer(clientX: number, clientY: number) { + const rect = this.domElement.getBoundingClientRect() + const width = rect.width || this.domElement.clientWidth || 1 + const height = rect.height || this.domElement.clientHeight || 1 + + this.pointer.x = ((clientX - rect.left) / width) * 2 - 1 + this.pointer.y = -((clientY - rect.top) / height) * 2 + 1 + } + + /** Run the raycaster and build a pick event out of the closest hit */ + private raycast(nativeEvent?: PointerEvent): PickEvent | null { + const objects = this.targets ?? this.scene.children + + this.raycaster.setFromCamera(this.pointer, this.camera) + + let intersections = this.raycaster.intersectObjects(objects, this.recursive) + + if (this.filter) { + const filter = this.filter + intersections = intersections.filter((item) => filter(item.object)) + } + + const intersection = intersections[0] + if (!intersection) return null + + return { + object: intersection.object, + point: intersection.point, + distance: intersection.distance, + intersection, + intersections, + pointer: this.pointer.clone(), + nativeEvent, + } + } + + private emit(type: PickEventType, event: PickEvent | null) { + this.listeners[type].forEach((listener) => listener(event)) + } + + private onPointerDown = (event: PointerEvent) => { + if (!this.enabled) return + + this.pointerDownX = event.clientX + this.pointerDownY = event.clientY + this.pointerDownValid = true + } + + private onPointerUp = (event: PointerEvent) => { + if (!this.enabled || !this.pointerDownValid) return + + this.pointerDownValid = false + + // Dragging the camera around should not be reported as a click + const dx = event.clientX - this.pointerDownX + const dy = event.clientY - this.pointerDownY + if (Math.sqrt(dx * dx + dy * dy) > this.clickThreshold) return + + if (this.listeners.click.size === 0) return + + this.updatePointer(event.clientX, event.clientY) + this.emit('click', this.raycast(event)) + } + + private onPointerMove = (event: PointerEvent) => { + if (!this.enabled || !this.enableHover) return + if (this.listeners.hover.size === 0) return + + // Raycasting on every move event is wasteful, coalesce them into one frame + this.pendingMove = event + if (this.hoverFrameId !== null) return + + this.hoverFrameId = requestAnimationFrame(this.processMove) + } + + private processMove = () => { + this.hoverFrameId = null + + const event = this.pendingMove + this.pendingMove = null + if (!event) return + + this.updatePointer(event.clientX, event.clientY) + const result = this.raycast(event) + const object = result ? result.object : null + + if (object === this.hovered) return + + this.hovered = object + this.emit('hover', result) + } + + private onPointerLeave = () => { + this.pointerDownValid = false + + if (this.hovered === null) return + + this.hovered = null + this.emit('hover', null) + } + + /** Subscribe to a pick event, returns an unsubscribe function */ + on(type: PickEventType, listener: PickCallback) { + this.listeners[type].add(listener) + return () => this.off(type, listener) + } + + /** Unsubscribe from a pick event */ + off(type: PickEventType, listener: PickCallback) { + this.listeners[type].delete(listener) + } + + /** Pick at a client position without waiting for a pointer event */ + pickAt(clientX: number, clientY: number) { + this.updatePointer(clientX, clientY) + return this.raycast() + } + + /** Replace the objects to test against, pass undefined to use the whole scene */ + setTargets(targets?: THREE.Object3D[]) { + this.targets = targets + } + + /** Replace the intersection filter, pass undefined to accept every object */ + setFilter(filter?: (object: THREE.Object3D) => boolean) { + this.filter = filter + } + + /** Enable or disable the picker without detaching it */ + setEnabled(enabled: boolean) { + this.enabled = enabled + if (!enabled) this.onPointerLeave() + } + + /** The object currently under the pointer */ + getHovered() { + return this.hovered + } + + /** Detach listeners and release references */ + dispose() { + this.domElement.removeEventListener('pointerdown', this.onPointerDown) + this.domElement.removeEventListener('pointerup', this.onPointerUp) + this.domElement.removeEventListener('pointermove', this.onPointerMove) + this.domElement.removeEventListener('pointerleave', this.onPointerLeave) + + if (this.hoverFrameId !== null) { + cancelAnimationFrame(this.hoverFrameId) + this.hoverFrameId = null + } + + this.pendingMove = null + this.hovered = null + this.listeners.click.clear() + this.listeners.hover.clear() + } +} + +export default Picker From aa3033617f963f9dff1fa505326f598ab5f8b570 Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Fri, 11 Sep 2026 17:18:46 +0800 Subject: [PATCH 03/13] feat(light-pillar): add animated light pillar effect - Add LightPillar group with TSL cone, top glow and ground pulse rings - Share one material per layer and drive per-mesh data via attributes - Auto-detect the renderer and pre-correct color on WebGPU - Dispose geometries, materials, the added listener and the rAF loop - Export LightPillar and LightPillarOptions from the public entry point - Add unit tests, dual-renderer demo and bilingual documentation - Drop the unused options argument from the Popup demo - Add a default export condition to package.json for CJS consumers --- docs/guide/effects/light-pillar.md | 154 ++++++++ .../current.json | 4 + .../current/effects/light-pillar.md | 153 ++++++++ docs/sidebars.js | 2 +- docs/src/components/Popup.tsx | 2 +- docs/src/components/effects/LightPillar.tsx | 50 +++ package.json | 3 +- src/__tests__/LightPillar.test.ts | 191 ++++++++++ src/__tests__/api.test.ts | 2 + src/index.ts | 17 +- src/utils/LightPillar.ts | 354 ++++++++++++++++++ 11 files changed, 927 insertions(+), 5 deletions(-) create mode 100644 docs/guide/effects/light-pillar.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md create mode 100644 docs/src/components/effects/LightPillar.tsx create mode 100644 src/__tests__/LightPillar.test.ts create mode 100644 src/utils/LightPillar.ts diff --git a/docs/guide/effects/light-pillar.md b/docs/guide/effects/light-pillar.md new file mode 100644 index 0000000..a9ec506 --- /dev/null +++ b/docs/guide/effects/light-pillar.md @@ -0,0 +1,154 @@ +--- +id: light-pillar +lang: en-US +title: Light Pillar +--- + +## Type + +Class + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import LightPillar from '@site/src/components/effects/LightPillar' + +A conical light pillar built from a translucent outer cone, a brighter inner core, a breathing glow at the tip and two pulse rings diffusing on the ground. It is commonly used as a data marker: map a normalized value to the `height` option so taller pillars mean larger values. + +## Default Usage + +Every example below can be viewed with either the **WebGPU** (default) or the **WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import { Scene, LightPillar } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + import type * as THREE from 'three' + + export default function App() { + const pillarsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 2.4, 6) + camera.lookAt(0, 1, 0) + + const data = [ + { x: -2.2, z: 0, value: 0.35, color: 0x00ffff }, + { x: 0, z: -0.6, value: 1, color: 0x36e0a0 }, + { x: 2.2, z: 0, value: 0.62, color: 0xffb347 } + ] + + data.forEach(({ x, z, value, color }) => { + const pillar = new LightPillar({ + color, + height: 1 + value * 2.4, + radius: 0.22 + }) + pillar.position.set(x, 0, z) + scene.add(pillar) + pillarsRef.current.push(pillar) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + pillarsRef.current.forEach((pillar) => pillar.dispose()) + pillarsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + + ```tsx + import { useRef, useEffect } from 'react' + import { Scene, LightPillar } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + import type * as THREE from 'three' + + export default function App() { + const pillarsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 2.4, 6) + camera.lookAt(0, 1, 0) + + const data = [ + { x: -2.2, z: 0, value: 0.35, color: 0x00ffff }, + { x: 0, z: -0.6, value: 1, color: 0x36e0a0 }, + { x: 2.2, z: 0, value: 0.62, color: 0xffb347 } + ] + + data.forEach(({ x, z, value, color }) => { + const pillar = new LightPillar({ + color, + height: 1 + value * 2.4, + radius: 0.22 + }) + pillar.position.set(x, 0, z) + scene.add(pillar) + pillarsRef.current.push(pillar) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + pillarsRef.current.forEach((pillar) => pillar.dispose()) + pillarsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + +## Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| color | number | 0x00ffff | Pillar color (cyan by default) | +| height | number | 3 | Pillar height along the Y axis, the base sits on `y = 0` | +| radius | number | 0.3 | Radius of the cone base | +| opacity | number | 1 | Global opacity multiplier (0-1) | +| speed | number | 1 | Animation speed multiplier | +| segments | number | 32 | Radial segment count of the cones and rings | +| glow | boolean | true | Render the breathing glow at the top of the pillar | +| ring | boolean | true | Render the two diffusing pulse rings at the base | +| ringRadius | number | radius * 3 | Outer radius the pulse rings expand to | + +## Methods + +| Name | Parameters | Description | +|------|------------|-------------| +| constructor | (options?: LightPillarOptions) | Create a light pillar. It extends `THREE.Group`, so add it to the scene and move it with `position` | +| dispose | () => void | Dispose the pillar, stop the animation and remove it from the scene. Should be called on component unmount to prevent memory leaks | diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json index ff8fdf0..fb85e29 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json @@ -86,5 +86,9 @@ "sidebar.guideSidebar.doc.effects/light-gradient": { "message": "灯光渐变", "description": "The label for doc 'effects/light-gradient' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.effects/light-pillar": { + "message": "光柱效果", + "description": "The label for doc 'effects/light-pillar' in sidebar 'guideSidebar'" } } diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md new file mode 100644 index 0000000..23b03a5 --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md @@ -0,0 +1,153 @@ +--- +lang: zh-CN +title: 光柱效果 +--- + +## 类型 + +类 + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import LightPillarComponent from '@site/src/components/effects/LightPillar' + +由半透明外锥、更亮的内层光芯、顶部呼吸光晕以及两圈地面扩散脉冲环组成的锥形光柱。常用作数据标记:把归一化后的数值映射到 `height` 配置项,柱体越高代表数值越大。 + +## 默认用法 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import { Scene, LightPillar } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + import type * as THREE from 'three' + + export default function App() { + const pillarsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 2.4, 6) + camera.lookAt(0, 1, 0) + + const data = [ + { x: -2.2, z: 0, value: 0.35, color: 0x00ffff }, + { x: 0, z: -0.6, value: 1, color: 0x36e0a0 }, + { x: 2.2, z: 0, value: 0.62, color: 0xffb347 } + ] + + data.forEach(({ x, z, value, color }) => { + const pillar = new LightPillar({ + color, + height: 1 + value * 2.4, + radius: 0.22 + }) + pillar.position.set(x, 0, z) + scene.add(pillar) + pillarsRef.current.push(pillar) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + pillarsRef.current.forEach((pillar) => pillar.dispose()) + pillarsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + + ```tsx + import { useRef, useEffect } from 'react' + import { Scene, LightPillar } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + import type * as THREE from 'three' + + export default function App() { + const pillarsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 2.4, 6) + camera.lookAt(0, 1, 0) + + const data = [ + { x: -2.2, z: 0, value: 0.35, color: 0x00ffff }, + { x: 0, z: -0.6, value: 1, color: 0x36e0a0 }, + { x: 2.2, z: 0, value: 0.62, color: 0xffb347 } + ] + + data.forEach(({ x, z, value, color }) => { + const pillar = new LightPillar({ + color, + height: 1 + value * 2.4, + radius: 0.22 + }) + pillar.position.set(x, 0, z) + scene.add(pillar) + pillarsRef.current.push(pillar) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + pillarsRef.current.forEach((pillar) => pillar.dispose()) + pillarsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + +## 配置项 + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| color | number | 0x00ffff | 光柱颜色,默认青色 | +| height | number | 3 | 光柱沿 Y 轴的高度,底面位于 `y = 0` | +| radius | number | 0.3 | 锥体底面半径 | +| opacity | number | 1 | 全局不透明度系数(0-1) | +| speed | number | 1 | 动画速度系数 | +| segments | number | 32 | 锥体与圆环的径向分段数 | +| glow | boolean | true | 是否渲染顶部呼吸光晕 | +| ring | boolean | true | 是否渲染底部的两圈扩散脉冲环 | +| ringRadius | number | radius * 3 | 脉冲环扩散到的外半径 | + +## 方法 + +| 名称 | 参数 | 描述 | +|------|------|------| +| constructor | (options?: LightPillarOptions) | 创建光柱。它继承自 `THREE.Group`,直接加入场景并通过 `position` 摆放 | +| dispose | () => void | 销毁光柱、停止动画并从场景中移除。组件卸载时应调用以避免内存泄漏 | diff --git a/docs/sidebars.js b/docs/sidebars.js index c241878..da34ee9 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -27,7 +27,7 @@ const sidebars = { type: 'category', label: 'Effect', collapsible: true, - items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/sweep-light', 'effects/light-gradient'], + items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/sweep-light', 'effects/light-gradient', 'effects/light-pillar'], }, ], }; diff --git a/docs/src/components/Popup.tsx b/docs/src/components/Popup.tsx index 03055c0..465d722 100644 --- a/docs/src/components/Popup.tsx +++ b/docs/src/components/Popup.tsx @@ -17,7 +17,7 @@ export default function PopupComponent({ rendererType = 'webgpu' }: PopupProps = camera.position.set(0, 1.5, 3) // Create popup with React component at starting position [0, 0.5, 0] - const popup = new Popup([0, 0.5, 0], , {}) + const popup = new Popup([0, 0.5, 0], ) scene.add(popup.scene!) // Move popup vertically upward to [0, 1.2, 0] with 2 seconds animation diff --git a/docs/src/components/effects/LightPillar.tsx b/docs/src/components/effects/LightPillar.tsx new file mode 100644 index 0000000..833b2b4 --- /dev/null +++ b/docs/src/components/effects/LightPillar.tsx @@ -0,0 +1,50 @@ +import { useRef, useEffect } from 'react' +import { Scene, LightPillar } from 'react-three-lite' +import type { SceneComponents } from 'react-three-lite' +import type * as THREE from 'three' + +interface LightPillarProps { + rendererType?: 'webgpu' | 'webgl' +} + +export default function LightPillarComponent({ rendererType }: LightPillarProps) { + const pillarsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 2.4, 6) + camera.lookAt(0, 1, 0) + + const pillars: { x: number; z: number; value: number; color: number }[] = [ + { x: -2.2, z: 0, value: 0.35, color: 0x00ffff }, + { x: 0, z: -0.6, value: 1, color: 0x36e0a0 }, + { x: 2.2, z: 0, value: 0.62, color: 0xffb347 } + ] + + pillars.forEach(({ x, z, value, color }) => { + const pillar = new LightPillar({ + color, + // Map a normalized data value to the pillar height + height: 1 + value * 2.4, + radius: 0.22 + }) + pillar.position.set(x, 0, z) + scene.add(pillar) + pillarsRef.current.push(pillar) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + pillarsRef.current.forEach((pillar) => pillar.dispose()) + pillarsRef.current = [] + } + }, []) + + return ( + + ) +} diff --git a/package.json b/package.json index eb6fa2f..8283c88 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/react-three-lite.js" + "import": "./dist/react-three-lite.js", + "default": "./dist/react-three-lite.js" }, "./dist/style.css": "./dist/style.css" }, diff --git a/src/__tests__/LightPillar.test.ts b/src/__tests__/LightPillar.test.ts new file mode 100644 index 0000000..d9589d2 --- /dev/null +++ b/src/__tests__/LightPillar.test.ts @@ -0,0 +1,191 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LightPillar from '../utils/LightPillar' +import { installFakeRaf, type FakeRaf } from './helpers/fakeRaf' + +// The uniforms, the geometry cache and the `added` listener are private +// implementation details, but they are exactly what the resource cleanup +// contract touches, so the tests read them through a narrow structural cast +// instead of widening the public API. +interface LightPillarInternals { + animationId: number | null + bundles: { timeUniform: { value: number }; colorUniform: { value: THREE.Color }; material: THREE.Material }[] + geometries: THREE.BufferGeometry[] + onAdded: () => void +} + +function internals(pillar: LightPillar): LightPillarInternals { + return pillar as unknown as LightPillarInternals +} + +describe('LightPillar', () => { + let raf: FakeRaf + + beforeEach(() => { + raf = installFakeRaf() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('builds the cones, the glow and the two pulse rings', () => { + const pillar = new LightPillar() + + // 2 cones + 1 glow + 2 rings + expect(pillar.children).toHaveLength(5) + expect(internals(pillar).geometries).toHaveLength(5) + + pillar.dispose() + }) + + it('skips the optional glow and rings when they are disabled', () => { + const pillar = new LightPillar({ glow: false, ring: false }) + + expect(pillar.children).toHaveLength(2) + // A single shared cone material drives both cones. + expect(internals(pillar).bundles).toHaveLength(1) + + pillar.dispose() + }) + + it('shares one material between the outer shell and the inner core', () => { + const pillar = new LightPillar() + const [outer, inner] = pillar.children as THREE.Mesh[] + + expect(outer.material).toBe(inner.material) + + pillar.dispose() + }) + + it('stands the cones on the ground plane', () => { + const pillar = new LightPillar({ height: 4 }) + const outer = pillar.children[0] as THREE.Mesh + + outer.geometry.computeBoundingBox() + const box = outer.geometry.boundingBox! + + expect(box.min.y).toBeCloseTo(0) + expect(box.max.y).toBeCloseTo(4) + + pillar.dispose() + }) + + it('starts a frame loop from the constructor', () => { + const pillar = new LightPillar() + + expect(raf.request).toHaveBeenCalledTimes(1) + + pillar.dispose() + }) + + it('advances every time uniform on each frame', () => { + const pillar = new LightPillar() + + raf.flush() + + internals(pillar).bundles.forEach((bundle) => { + expect(bundle.timeUniform.value).toBeGreaterThanOrEqual(0) + }) + + pillar.dispose() + }) + + it('stops the frame loop on dispose', () => { + const pillar = new LightPillar() + + pillar.dispose() + + expect(raf.cancel).toHaveBeenCalledTimes(1) + expect(raf.pending.size).toBe(0) + expect(internals(pillar).animationId).toBeNull() + + const requestCount = raf.request.mock.calls.length + raf.flush() + expect(raf.request.mock.calls.length).toBe(requestCount) + }) + + it('removes the added listener on dispose so the pillar can be garbage collected', () => { + const pillar = new LightPillar() + const { onAdded } = internals(pillar) + expect(pillar.hasEventListener('added', onAdded)).toBe(true) + + pillar.dispose() + + expect(pillar.hasEventListener('added', onAdded)).toBe(false) + }) + + it('releases every geometry and material on dispose', () => { + const pillar = new LightPillar() + const geometrySpies = internals(pillar).geometries.map((geometry) => vi.spyOn(geometry, 'dispose')) + const materialSpies = internals(pillar).bundles.map((bundle) => vi.spyOn(bundle.material, 'dispose')) + + pillar.dispose() + + geometrySpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1)) + materialSpies.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1)) + expect(pillar.children).toHaveLength(0) + }) + + it('detaches itself from the scene on dispose', () => { + const scene = new THREE.Scene() + const pillar = new LightPillar() + scene.add(pillar) + + pillar.dispose() + + expect(scene.children).not.toContain(pillar) + }) + + it('is safe to dispose twice', () => { + const pillar = new LightPillar() + + pillar.dispose() + expect(() => pillar.dispose()).not.toThrow() + expect(raf.cancel).toHaveBeenCalledTimes(1) + }) + + it('keeps raw colors when the scene renders through WebGL', () => { + const scene = new THREE.Scene() + scene.userData.renderer = { isWebGPURenderer: false } + const pillar = new LightPillar({ color: 0x336699 }) + + scene.add(pillar) + + const expected = new THREE.Color(0x336699) + internals(pillar).bundles.forEach((bundle) => { + expect(bundle.colorUniform.value.getHex()).toBe(expected.getHex()) + }) + + pillar.dispose() + }) + + it('pre-corrects colors when the scene renders through WebGPU', () => { + const scene = new THREE.Scene() + scene.userData.renderer = { isWebGPURenderer: true } + const pillar = new LightPillar({ color: 0x336699 }) + + scene.add(pillar) + + const brightness = 0.86 + const { r, g, b } = new THREE.Color(0x336699) + internals(pillar).bundles.forEach((bundle) => { + expect(bundle.colorUniform.value.r).toBeCloseTo(Math.pow(r, 2.2) * brightness) + expect(bundle.colorUniform.value.g).toBeCloseTo(Math.pow(g, 2.2) * brightness) + expect(bundle.colorUniform.value.b).toBeCloseTo(Math.pow(b, 2.2) * brightness) + }) + + pillar.dispose() + }) + + it('leaves colors untouched while the renderer is still unknown', () => { + const scene = new THREE.Scene() + const pillar = new LightPillar({ color: 0x336699 }) + + scene.add(pillar) + + expect(internals(pillar).bundles[0].colorUniform.value.getHex()).toBe(new THREE.Color(0x336699).getHex()) + + pillar.dispose() + }) +}) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index ebe24c5..115f3dd 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -16,6 +16,7 @@ const EXPECTED_EXPORTS = [ 'GLTFLoader', 'GLTFLoaderAsync', 'LightGradient', + 'LightPillar', 'ModelRotator', 'Movable', 'OBJLoader', @@ -57,6 +58,7 @@ describe('public API surface', () => { 'FlowLineMesh', 'GLTFLoader', 'LightGradient', + 'LightPillar', 'ModelRotator', 'Movable', 'OBJLoader', diff --git a/src/index.ts b/src/index.ts index 41189a0..a1dcd4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,22 +24,35 @@ import { import SweepLight from './utils/SweepLight' import ModelRotator from './utils/ModelRotator' import Picker from './utils/Picker' +import LightPillar from './utils/LightPillar' import { LightGradient, LightGradientOptions } from './utils/Light' import type { CalloutOptions, LineShape, BendAxis, LabelAnchor } from './utils/Callout' import type { PickEvent, PickEventType, PickCallback, PickerOptions } from './utils/Picker' +import type { LightPillarOptions } from './utils/LightPillar' // components -export { Scene, GLTFLoader, FBXLoader, OBJLoader, Bloom, Rain, Snow, SceneContext, useScene } +export { + Scene, + GLTFLoader, + FBXLoader, + OBJLoader, + Bloom, + Rain, + Snow, + SceneContext, + useScene +} // types export type { SceneComponents, CallbackFrame, R3LRenderer } from './context/SceneContext' export type { RendererType } from './utils/Renderer' export type { LightGradientOptions, CalloutOptions, LineShape, BendAxis, LabelAnchor } export type { PickEvent, PickEventType, PickCallback, PickerOptions } +export type { LightPillarOptions } // class -export { SkyBox, Popup, Callout, Movable, WaveCircleMesh, FlowLineMesh, Animation, SweepLight, LightGradient, ModelRotator, Picker } +export { SkyBox, Popup, Callout, Movable, WaveCircleMesh, FlowLineMesh, Animation, SweepLight, LightGradient, ModelRotator, Picker, LightPillar } // function - async loaders (no hooks) export { GLTFLoaderFn as GLTFLoaderAsync, FBXLoaderFn as FBXLoaderAsync, OBJLoaderFn as OBJLoaderAsync } diff --git a/src/utils/LightPillar.ts b/src/utils/LightPillar.ts new file mode 100644 index 0000000..ab73771 --- /dev/null +++ b/src/utils/LightPillar.ts @@ -0,0 +1,354 @@ +import * as THREE from 'three' +import { NodeMaterial } from 'three/webgpu' +import { + Fn, + uniform, + float, + vec2, + vec4, + uv, + positionLocal, + modelViewMatrix, + cameraProjectionMatrix, + attribute, + varying, + fract, + pow, + clamp, + mix, + sin, + distance, + oneMinus, +} from 'three/tsl' + +export interface LightPillarOptions { + /** Pillar color, default: 0x00ffff (cyan) */ + color?: number + /** Pillar height along the Y axis, default: 3 */ + height?: number + /** Radius of the cone base sitting on the ground, default: 0.3 */ + radius?: number + /** Global opacity multiplier (0-1), default: 1 */ + opacity?: number + /** Animation speed multiplier, default: 1 */ + speed?: number + /** Radial segment count of the cones and rings, default: 32 */ + segments?: number + /** Render the breathing glow at the top of the pillar, default: true */ + glow?: boolean + /** Render the two diffusing pulse rings at the base, default: true */ + ring?: boolean + /** Outer radius the pulse rings expand to, default: radius * 3 */ + ringRadius?: number +} + +interface MaterialBundle { + material: NodeMaterial + timeUniform: { value: number } + colorUniform: { value: THREE.Color } +} + +// Walk up the parent chain to find the scene an object belongs to +function findScene(start: THREE.Object3D): THREE.Scene | null { + let current: THREE.Object3D | null = start + while (current) { + if ((current as THREE.Scene).isScene) return current as THREE.Scene + current = current.parent + } + return null +} + +// The cone material is shared by the outer shell and the inner core: a +// NodeMaterial per mesh would multiply the compiled programs (and the WebGL2 +// uniform buffer bindings) for every pillar in the scene, so the per-mesh +// brightness is supplied through the `aIntensity` geometry attribute instead. +function createConeMaterial(color: number, speed: number, opacity: number): MaterialBundle { + const uTime = uniform(0) + const uColor = uniform(new THREE.Color(color)) + const uSpeed = uniform(speed) + const uOpacity = uniform(opacity) + + const vIntensity = varying(float(0)) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + material.blending = THREE.AdditiveBlending + material.side = THREE.DoubleSide + + material.vertexNode = Fn(() => { + vIntensity.assign(attribute<'float'>('aIntensity', 'float')) + + // The vertex node output is assigned straight to gl_Position by the + // WebGLNodesHandler, so it has to be a fully transformed vec4. + return cameraProjectionMatrix.mul(modelViewMatrix).mul(vec4(positionLocal, 1.0)) + })() + + material.fragmentNode = Fn(() => { + // Open-ended CylinderGeometry maps v from 0 at the base to 1 at the tip. + const y = uv().y + + // Base gradient: the pillar is dense on the ground and dissolves upwards. + const gradient = pow(oneMinus(y), float(1.6)) + + // Energy band travelling from the base to the tip. + const band = pow(fract(y.sub(uTime.mul(uSpeed))), float(9.0)) + + const alpha = clamp( + gradient.mul(0.7).add(band.mul(0.6)).mul(vIntensity).mul(uOpacity), + float(0.0), + float(1.0) + ) + + return vec4(uColor.rgb, alpha) + })() + + return { material, timeUniform: uTime as unknown as { value: number }, colorUniform: uColor as unknown as { value: THREE.Color } } +} + +function createGlowMaterial(color: number, size: number, speed: number, opacity: number): MaterialBundle { + const uTime = uniform(0) + const uColor = uniform(new THREE.Color(color)) + const uSize = uniform(size) + const uSpeed = uniform(speed) + const uOpacity = uniform(opacity) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + material.blending = THREE.AdditiveBlending + material.side = THREE.DoubleSide + + material.vertexNode = Fn(() => { + // View space billboard: keep the quad facing the camera while the mesh + // origin still follows the pillar tip through the model view matrix. + const centerView = modelViewMatrix.mul(vec4(0.0, 0.0, 0.0, 1.0)) + const breathing = float(1.0).add(sin(uTime.mul(uSpeed).mul(2.0)).mul(0.18)) + const scaled = uSize.mul(breathing) + + return cameraProjectionMatrix.mul( + vec4( + centerView.x.add(positionLocal.x.mul(scaled)), + centerView.y.add(positionLocal.y.mul(scaled)), + centerView.z, + 1.0 + ) + ) + })() + + material.fragmentNode = Fn(() => { + const d = distance(uv(), vec2(0.5, 0.5)).mul(2.0) + const core = pow(clamp(oneMinus(d), float(0.0), float(1.0)), float(2.6)) + + return vec4(uColor.rgb, core.mul(uOpacity)) + })() + + return { material, timeUniform: uTime as unknown as { value: number }, colorUniform: uColor as unknown as { value: THREE.Color } } +} + +// Both pulse rings share one material; the half cycle offset between them comes +// from the per-ring `aPhase` attribute rather than a second material. +function createRingMaterial(color: number, speed: number, opacity: number): MaterialBundle { + const uTime = uniform(0) + const uColor = uniform(new THREE.Color(color)) + const uSpeed = uniform(speed) + const uOpacity = uniform(opacity) + + const vProgress = varying(float(0)) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + material.blending = THREE.AdditiveBlending + material.side = THREE.DoubleSide + + material.vertexNode = Fn(() => { + const phase = attribute<'float'>('aPhase', 'float') + const progress = fract(uTime.mul(uSpeed).mul(0.5).add(phase)) + vProgress.assign(progress) + + const scaled = positionLocal.mul(mix(float(0.35), float(1.0), progress)) + + return cameraProjectionMatrix.mul(modelViewMatrix).mul(vec4(scaled, 1.0)) + })() + + material.fragmentNode = Fn(() => { + const alpha = pow(oneMinus(vProgress), float(1.8)).mul(uOpacity).mul(0.9) + + return vec4(uColor.rgb, alpha) + })() + + return { material, timeUniform: uTime as unknown as { value: number }, colorUniform: uColor as unknown as { value: THREE.Color } } +} + +function fillAttribute(geometry: THREE.BufferGeometry, name: string, value: number): void { + const count = geometry.attributes.position.count + const array = new Float32Array(count) + array.fill(value) + geometry.setAttribute(name, new THREE.BufferAttribute(array, 1)) +} + +function createConeGeometry( + radiusBottom: number, + radiusTop: number, + height: number, + segments: number, + intensity: number +): THREE.BufferGeometry { + const geometry = new THREE.CylinderGeometry(radiusTop, radiusBottom, height, segments, 1, true) + // CylinderGeometry is centred on the origin, move the base onto the ground. + geometry.translate(0, height / 2, 0) + fillAttribute(geometry, 'aIntensity', intensity) + + return geometry +} + +function createRingGeometry(radius: number, segments: number, phase: number): THREE.BufferGeometry { + const geometry = new THREE.RingGeometry(radius * 0.84, radius, segments) + // RingGeometry is built on the XY plane, lay it flat on the ground. + geometry.rotateX(-Math.PI / 2) + fillAttribute(geometry, 'aPhase', phase) + + return geometry +} + +export default class LightPillar extends THREE.Group { + private animationId: number | null = null + private readonly startTime: number + private readonly bundles: MaterialBundle[] = [] + private readonly geometries: THREE.BufferGeometry[] = [] + private readonly origColor: THREE.Color + private readonly onAdded: () => void + private disposed = false + + constructor(options: LightPillarOptions = {}) { + super() + + const { + color = 0x00ffff, + height = 3, + radius = 0.3, + opacity = 1, + speed = 1, + segments = 32, + glow = true, + ring = true, + ringRadius = radius * 3, + } = options + + this.origColor = new THREE.Color(color) + + const cone = createConeMaterial(color, speed, opacity) + this.bundles.push(cone) + + const outerGeometry = createConeGeometry(radius, radius * 0.12, height, segments, 0.75) + const innerGeometry = createConeGeometry(radius * 0.42, radius * 0.05, height, segments, 1.4) + this.geometries.push(outerGeometry, innerGeometry) + this.add(new THREE.Mesh(outerGeometry, cone.material)) + this.add(new THREE.Mesh(innerGeometry, cone.material)) + + if (glow) { + const glowBundle = createGlowMaterial(color, radius * 3, speed, opacity) + this.bundles.push(glowBundle) + + const glowGeometry = new THREE.PlaneGeometry(1, 1) + this.geometries.push(glowGeometry) + + const glowMesh = new THREE.Mesh(glowGeometry, glowBundle.material) + glowMesh.position.y = height + // The billboard is expanded in clip space, so the geometry bounds no + // longer describe what is actually drawn. + glowMesh.frustumCulled = false + this.add(glowMesh) + } + + if (ring) { + const ringBundle = createRingMaterial(color, speed, opacity) + this.bundles.push(ringBundle) + + for (const phase of [0, 0.5]) { + const ringGeometry = createRingGeometry(ringRadius, segments, phase) + this.geometries.push(ringGeometry) + + const ringMesh = new THREE.Mesh(ringGeometry, ringBundle.material) + ringMesh.position.y = 0.01 + ringMesh.frustumCulled = false + this.add(ringMesh) + } + } + + // Auto-detect the renderer from the scene the pillar belongs to: WebGPU + // converts linear to sRGB on final output, so pre-correct the color so it + // matches the WebGL output. + this.onAdded = () => { + const scene = this.getScene() + const renderer = scene?.userData?.renderer as { isWebGPURenderer?: boolean } | undefined + if (renderer) this.applyColor(renderer.isWebGPURenderer === true) + } + this.addEventListener('added', this.onAdded) + + this.startTime = performance.now() + this.startAnimate() + } + + // Walk up the parent chain to find the scene the pillar belongs to + private getScene(): THREE.Scene | null { + return findScene(this) + } + + // Set the color according to the renderer type; WebGPU needs sRGB brightness + // pre-correction so it matches the WebGL output. + private applyColor(isWebGPU: boolean): void { + const { r, g, b } = this.origColor + + for (const bundle of this.bundles) { + if (isWebGPU) { + const brightness = 0.86 + bundle.colorUniform.value.setRGB( + Math.pow(r, 2.2) * brightness, + Math.pow(g, 2.2) * brightness, + Math.pow(b, 2.2) * brightness + ) + } else { + bundle.colorUniform.value.copy(this.origColor) + } + } + } + + private startAnimate(): void { + const tick = () => { + this.animationId = requestAnimationFrame(tick) + + const elapsed = (performance.now() - this.startTime) / 1000 + for (const bundle of this.bundles) { + bundle.timeUniform.value = elapsed + } + } + + tick() + } + + public dispose(): void { + if (this.disposed) return + this.disposed = true + + if (this.animationId !== null) { + cancelAnimationFrame(this.animationId) + this.animationId = null + } + + this.removeEventListener('added', this.onAdded) + this.parent?.remove(this) + this.clear() + + for (const geometry of this.geometries) { + geometry.dispose() + } + for (const bundle of this.bundles) { + bundle.material.dispose() + } + + this.geometries.length = 0 + this.bundles.length = 0 + } +} From 87bc02e36a086f99f314c1d3ab1d244815e7ab0f Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Thu, 1 Oct 2026 05:55:32 +0800 Subject: [PATCH 04/13] feat(gis): add geographic reference system and map layers - Add WGS84/GCJ02/BD09 datum conversion, recentered Web Mercator and local tangent plane projections, and a GeoReference for geographic-to-local mapping - Add Scene origin/crs/datum/axes/geo props, plus object-form gridHelper options - Add GeoObject to anchor objects at geographic coordinates and GeoJsonLayer for GeoJSON geometries - Add TileLayer that streams XYZ raster tiles with quadtree selection, worker image decode and crossfade - Add tests, dual-renderer demos and bilingual documentation COMMIT_EXIT= --- AGENTS.md | 4 - CHANGELOG.md | 18 + docs/guide/gis/geo-object.md | 173 +++ docs/guide/gis/geo-reference.md | 439 ++++++ docs/guide/gis/geojson-layer.md | 237 ++++ docs/guide/gis/tile-layer.md | 206 +++ .../current.json | 20 + .../current.json | 20 + .../current/gis/geo-object.md | 170 +++ .../current/gis/geo-reference.md | 417 ++++++ .../current/gis/geojson-layer.md | 233 ++++ .../current/gis/tile-layer.md | 191 +++ docs/sidebars.js | 11 + docs/src/components/gis/GeoJsonLayer.tsx | 106 ++ docs/src/components/gis/GeoObject.tsx | 54 + docs/src/components/gis/GeoReference.tsx | 73 + docs/src/components/gis/TileLayer.tsx | 62 + src/__tests__/CameraFit.test.ts | 247 ++++ src/__tests__/Datum.test.ts | 62 + src/__tests__/FitCamera.test.ts | 296 ++++ src/__tests__/GeoJson.test.ts | 112 ++ src/__tests__/GeoReference.test.ts | 124 ++ src/__tests__/GroundSampling.test.ts | 113 ++ src/__tests__/Mercator.test.ts | 42 + src/__tests__/SceneGeo.test.ts | 158 +++ src/__tests__/TileLayer.integration.test.ts | 168 +++ src/__tests__/TilePyramid.test.ts | 284 ++++ src/__tests__/Tiles.test.ts | 243 ++++ src/__tests__/api.test.ts | 70 +- src/components/GeoJsonLayer.tsx | 233 ++++ src/components/GeoObject.tsx | 110 ++ src/components/Scene.tsx | 97 +- src/components/TileLayer.tsx | 1206 +++++++++++++++++ src/context/SceneContext.tsx | 7 +- src/crs/Datum.ts | 105 ++ src/crs/GeoJson.ts | 133 ++ src/crs/GeoReference.ts | 139 ++ src/crs/LocalPlane.ts | 73 + src/crs/Mercator.ts | 38 + src/crs/SceneGeo.ts | 137 ++ src/crs/TilePyramid.ts | 267 ++++ src/crs/Tiles.ts | 166 +++ src/crs/constants.ts | 41 + src/crs/index.ts | 50 + src/crs/types.ts | 79 ++ src/index.ts | 102 +- src/utils/Camera.ts | 15 +- src/utils/CameraFit.ts | 300 ++++ src/utils/FitCamera.ts | 87 ++ src/utils/GroundSampling.ts | 74 + src/utils/Scene.ts | 20 +- 51 files changed, 7811 insertions(+), 21 deletions(-) create mode 100644 docs/guide/gis/geo-object.md create mode 100644 docs/guide/gis/geo-reference.md create mode 100644 docs/guide/gis/geojson-layer.md create mode 100644 docs/guide/gis/tile-layer.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-object.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/tile-layer.md create mode 100644 docs/src/components/gis/GeoJsonLayer.tsx create mode 100644 docs/src/components/gis/GeoObject.tsx create mode 100644 docs/src/components/gis/GeoReference.tsx create mode 100644 docs/src/components/gis/TileLayer.tsx create mode 100644 src/__tests__/CameraFit.test.ts create mode 100644 src/__tests__/Datum.test.ts create mode 100644 src/__tests__/FitCamera.test.ts create mode 100644 src/__tests__/GeoJson.test.ts create mode 100644 src/__tests__/GeoReference.test.ts create mode 100644 src/__tests__/GroundSampling.test.ts create mode 100644 src/__tests__/Mercator.test.ts create mode 100644 src/__tests__/SceneGeo.test.ts create mode 100644 src/__tests__/TileLayer.integration.test.ts create mode 100644 src/__tests__/TilePyramid.test.ts create mode 100644 src/__tests__/Tiles.test.ts create mode 100644 src/components/GeoJsonLayer.tsx create mode 100644 src/components/GeoObject.tsx create mode 100644 src/components/TileLayer.tsx create mode 100644 src/crs/Datum.ts create mode 100644 src/crs/GeoJson.ts create mode 100644 src/crs/GeoReference.ts create mode 100644 src/crs/LocalPlane.ts create mode 100644 src/crs/Mercator.ts create mode 100644 src/crs/SceneGeo.ts create mode 100644 src/crs/TilePyramid.ts create mode 100644 src/crs/Tiles.ts create mode 100644 src/crs/constants.ts create mode 100644 src/crs/index.ts create mode 100644 src/crs/types.ts create mode 100644 src/utils/CameraFit.ts create mode 100644 src/utils/FitCamera.ts create mode 100644 src/utils/GroundSampling.ts diff --git a/AGENTS.md b/AGENTS.md index 617a1c5..71d0322 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,8 +158,6 @@ export default function App() { const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { const { camera } = components - if (!camera) return - camera.position.set(0, 0, 4) camera.lookAt(0, 0, 0) @@ -359,8 +357,6 @@ function Component({ rendererType }: { rendererType?: 'webgpu' | const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { const { camera } = components - if (!camera) return - camera.position.set(0, 0, 4) camera.lookAt(0, 0, 0) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ffd54..0cc384b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## [Unreleased] + +### Added + +- Add GIS support with a geographic coordinate reference system. Geographic coordinates (`lng`, `lat`, `alt`) are normalized to WGS84, projected onto a local tangent plane and recentered around a configurable origin, preventing the float32 precision jitter that raw Web Mercator coordinates (around 1.3e7 metres) would cause. +- Add datum conversion between WGS84, GCJ02 and BD09 (`toWGS84`, `fromWGS84`, `convertDatum`, `wgs84ToGcj02`, `gcj02ToWgs84`, `gcj02ToBd09`, `bd09ToGcj02`), with a fixed-point GCJ02 inverse. +- Add projection helpers: recentered Web Mercator (`mercatorProject`, `mercatorUnproject`, `mercatorCRS`) and an equirectangular local tangent plane (`createLocalPlane`). +- Add the `GeoReference` class (and the `createGeoReference` factory) for geographic-to-local conversion, with `toLocal`, `toGeo`, `setPosition` and `distance` methods. ENU axes map east to `+X`, north to `-Z` and up to `+Y`. +- Add `origin`, `crs`, `datum`, `axes` and `geo` props to ``. The scene always owns a `GeoReference` (the WGS84 default at `0,0` is a no-op), exposed as `components.geo` in `onCreated` and as `geo` on the `useScene()` context. Omitting the new props keeps the previous behavior unchanged. +- Add the `GeoObject` component to declaratively anchor any `THREE.Object3D` at a geographic coordinate, with per-object datum override and in-place position updates when the coordinate changes. +- Add the `GeoJsonLayer` component for rendering GeoJSON `Point`, `LineString`, `Polygon` (including holes), `MultiPoint`, `MultiLineString`, `MultiPolygon`, `Feature` and `FeatureCollection` data, with base and per-feature styles and datum conversion. +- Add unit tests for datum conversion, projections, `GeoReference` and GeoJSON flattening, and extend the public API surface guard test with the new GIS exports. +- Add WebGPU / WebGL demo components and bilingual (English and Chinese) documentation for the GIS features. +- Allow the `gridHelper` prop of `` to accept a `GridHelperOptions` config object (`size`, `divisions`, `colorCenterLine`, `colorGrid`, `y`), so callers can customise the built-in grid without constructing a `THREE.GridHelper` themselves. Passing a `THREE.GridHelper` instance remains supported, and the scene owns disposal of the grid in both cases. +- Add the `TileLayer` component to project standard XYZ / slippy-map raster tiles (256 px, Web Mercator) onto the geographic ground plane. The layer fills the bounding rectangle of a set of coverage points, lazy-loads tile images with `{z}`/`{x}`/`{y}`/`{s}` URL templates, fades tiles in as they arrive and disposes every geometry, material and texture on unmount. Tile selection is datum-neutral and each tile corner is placed through the `GeoReference` in the imagery datum, so GCJ02 sources such as AMap stay seamless. A `maxTiles` safety cap prevents accidental mass requests, and `onReady` / `onTileError` report lifecycle events. +- Add slippy-map tile math utilities: `TILE_SIZE`, `lngLatToTile`, `tileToLngLatBounds`, `tileToCorners`, `selectTiles` and `tileUrl`. +- Add WebGPU / WebGL demo and bilingual documentation for `TileLayer`. + ## [0.5.0] - 2026-08-27 This release migrates the library from a WebGL-only implementation to a dual-renderer architecture built on Three.js TSL, with `WebGPURenderer` as the new default. diff --git a/docs/guide/gis/geo-object.md b/docs/guide/gis/geo-object.md new file mode 100644 index 0000000..00cfbbe --- /dev/null +++ b/docs/guide/gis/geo-object.md @@ -0,0 +1,173 @@ +--- +id: geo-object +lang: en-US +title: GeoObject +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/GeoObject' + +## Type + +Component + +## Default Usage + +`GeoObject` anchors an `Object3D` at a geographic coordinate. Without an +`object` prop it creates an empty `THREE.Group` and hands it to `onReady`, +where you can add markers, labels or loaded models. Updating `coordinate` or +`datum` moves the object in place without rebuilding it. + +Every example below can be viewed with either the **WebGPU** (default) or the +**WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + // Scene origin in WGS84. + const ORIGIN: GeoPoint = { lng: 116.391, lat: 39.907 } + + // GCJ02 coordinates, as returned by AMap/Tencent services. + const MARKERS: GeoPoint[] = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } + ] + + function decorate(object: THREE.Object3D) { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xffd166 }) + ) + cone.position.y = 60 + object.add(cone) + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1400, + divisions: 28, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the markers; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 750, z: 1000 }) + camera.lookAt(0, 0, 0) + } + + return ( + + {MARKERS.map((coordinate, index) => ( + + ))} + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + const ORIGIN: GeoPoint = { lng: 116.391, lat: 39.907 } + const MARKERS: GeoPoint[] = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } + ] + + function decorate(object: THREE.Object3D) { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xffd166 }) + ) + cone.position.y = 60 + object.add(cone) + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1400, + divisions: 28, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the markers; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 750, z: 1000 }) + camera.lookAt(0, 0, 0) + } + + return ( + + {MARKERS.map((coordinate, index) => ( + + ))} + + ) + } + ``` + + + +## Anchoring an Existing Object + +Passing your own `Object3D` keeps ownership with the caller: the component +reparents it into the Scene and restores the original parent on unmount. + +```tsx +const model = new THREE.Group() + + +``` + +## Props + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| coordinate | GeoPoint | — | Geographic anchor in the datum declared by `datum` | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | Scene datum | Datum of `coordinate` | +| object | THREE.Object3D | creates a Group | Existing object to anchor | +| onReady | (object: Object3D) => void | — | Called once with the anchored object | +| scene | THREE.Scene | context scene | Explicit scene for non-JSX usage | + +## Events + +| Name | Parameters | Description | +|------|------------|-------------| +| onReady | (object: THREE.Object3D) => void | Fired when the object has been added and positioned | diff --git a/docs/guide/gis/geo-reference.md b/docs/guide/gis/geo-reference.md new file mode 100644 index 0000000..a339889 --- /dev/null +++ b/docs/guide/gis/geo-reference.md @@ -0,0 +1,439 @@ +--- +id: geo-reference +lang: en-US +title: Geographic Reference +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/GeoReference' + +## Type + +Class + Scene props + +## Concept + +`GeoReference` binds a coordinate reference system, a geodetic datum and an +axis convention to a fixed **local origin**. The conversion pipeline is: + +```text +geographic (lng, lat, alt) + -> datum normalize to WGS84 + -> local tangent plane (float64 metres) + -> recenter around the local origin + -> ENU -> three.js axis mapping (East:+X, North:-Z, Up:+Y) + -> THREE.Vector3 (float32) +``` + +The local origin is what keeps scene coordinates small. Absolute Web Mercator +coordinates are around 1.3e7 metres, which would jitter badly in three.js +float32 positions at city scale; after recentering, every object sits within a +few kilometres of `(0, 0, 0)`. + +A `GeoReference` always exists on a Scene. When no GIS props are supplied it +uses the WGS84/equirectangular defaults centred at `(0, 0)`, which is a no-op +for ordinary non-geographic scenes, so this feature is fully opt-in. + +## Default Usage + +Every example below can be viewed with either the **WebGPU** (default) or the +**WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + const ORIGIN: GeoPoint = { lng: 116.397, lat: 39.909 } + const PLACES: { point: GeoPoint; color: number }[] = [ + { point: ORIGIN, color: 0xffd166 }, + { point: { lng: 116.4005, lat: 39.909 }, color: 0x4fc3f7 }, + { point: { lng: 116.397, lat: 39.9117 }, color: 0x81c784 }, + { point: { lng: 116.3935, lat: 39.9063 }, color: 0xe57373 } + ] + + function App() { + const objectsRef = useRef([]) + + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1200, + divisions: 24, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the landmarks; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + + PLACES.forEach(({ point, color }) => { + const radius = 35 + const marker = new THREE.Mesh( + new THREE.SphereGeometry(radius, 24, 16), + new THREE.MeshBasicMaterial({ color }) + ) + // The scene carries its own GeoReference and projects the point itself. + scene.setPosition(marker, point) + // Rest the sphere on the ground plane instead of burying its lower half. + marker.position.y = radius + scene.add(marker) + objectsRef.current.push(marker) + }) + } + + useEffect(() => { + return () => { + objectsRef.current.forEach((object) => { + object.removeFromParent() + const mesh = object as THREE.Mesh + mesh.geometry?.dispose() + ;(mesh.material as THREE.Material | undefined)?.dispose() + }) + objectsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + const ORIGIN: GeoPoint = { lng: 116.397, lat: 39.909 } + const PLACES: { point: GeoPoint; color: number }[] = [ + { point: ORIGIN, color: 0xffd166 }, + { point: { lng: 116.4005, lat: 39.909 }, color: 0x4fc3f7 } + ] + + function App() { + const objectsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the landmarks; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + + PLACES.forEach(({ point, color }) => { + const radius = 35 + const marker = new THREE.Mesh( + new THREE.SphereGeometry(radius, 24, 16), + new THREE.MeshBasicMaterial({ color }) + ) + // The scene carries its own GeoReference and projects the point itself. + scene.setPosition(marker, point) + // Rest the sphere on the ground plane instead of burying its lower half. + marker.position.y = radius + scene.add(marker) + objectsRef.current.push(marker) + }) + } + + useEffect(() => { + return () => { + objectsRef.current.forEach((object) => { + object.removeFromParent() + const mesh = object as THREE.Mesh + mesh.geometry?.dispose() + ;(mesh.material as THREE.Material | undefined)?.dispose() + }) + objectsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + +## Scene Props + +| Property | Type | Default | Description | +| -------- | ------------------------------ | ------------------------------------- | ---------------------------------------------------------------------- | +| origin | GeoPoint | `{ lng: 0, lat: 0, alt: 0 }` | Local tangent-plane origin; all scene coordinates are relative to it | +| crs | `'EPSG:4326' \| 'EPSG:3857'` | `'EPSG:4326'` | Planar model: equirectangular tangent plane or recentered Web Mercator | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | `'WGS84'` | Datum of the coordinates passed to the scene | +| axes | AxesMapping | `{ east: 'x', north: '-z', up: 'y' }` | Maps the ENU basis onto signed three.js axes | +| geo | GeoReference | — | Supply a pre-built reference instead of `origin`/`crs`/`datum`/`axes` | + +The reference built by the Scene is available as `components.geo` inside the +`onCreated(scene, components)` callback and via `useScene().geo`. It is also +bound to the scene itself, so most code never has to name `geo` at all. + +## Positioning Objects + +Every Scene binds its `GeoReference` to the scene instance and installs one +positioning helper, `scene.setPosition(object, position, datum?)`. Because the +scene already knows its own reference, a geographic position needs only the +coordinates themselves — no reference threading — and the exact same call places +a mesh, a group or a camera, since all of them are `THREE.Object3D`. + +The position accepts every convenient shape (`ScenePosition`): + +| Position shape | Interpretation | +| --- | --- | +| `{ lng, lat, alt? }` | Geographic point, projected through the scene's reference | +| `[lng, lat]` | Geographic point, altitude defaults to `0` | +| `[lng, lat, alt]` | Geographic point as a plain tuple | +| `THREE.Vector3` | Local metres, used as-is (no projection) | +| `{ x, y, z? }` | Local metres as a plain object, `z` defaults to `0` | + +```ts +const handleCreated = (scene: THREE.Scene) => { + // All four are geographic and projected through the scene's own reference. + scene.setPosition(mesh, { lng: 116.397, lat: 39.909 }) + scene.setPosition(mesh, { lng: 116.397, lat: 39.909, alt: 35 }) + scene.setPosition(mesh, [116.397, 39.909]) + scene.setPosition(mesh, [116.397, 39.909, 35]) + + // A per-call datum overrides the scene datum for GCJ02/BD09 input. + scene.setPosition(mesh, [116.403, 39.915], 'GCJ02') + + // A camera is positioned with the identical call. + scene.setPosition(camera, [116.397428, 39.90923, 120]) + + // Local metres are used directly, no projection. + scene.setPosition(helper, new THREE.Vector3(10, 0, -5)) + scene.setPosition(helper, { x: 10, y: 0, z: -5 }) +} +``` + +The method returns the object passed in, so calls can chain. A scene you create +yourself without R3L has no helper; either call `bindSceneGeo(scene, geo)` once +or read the reference with `getSceneGeo(scene)`. On a scene with helpers but no +bound reference, geographic tuples/objects fall back to local coordinates and +map `lng/lat/alt` straight onto `x/y/z`, so the call is always safe. + +| Helper | Signature | Description | +| --- | --- | --- | +| scene.setPosition | (object: Object3D, position: ScenePosition, datum?: DatumType) => Object3D | Place any object; geographic inputs are projected, local metres used as-is | +| scene.getGeo | () => GeoReference \| undefined | Read the reference bound to the scene | +| bindSceneGeo | (scene: Scene, geo?: GeoReference) => Scene | Install the helpers on a scene and optionally bind a reference | +| getSceneGeo | (scene: Scene) => GeoReference \| undefined | Functional equivalent of `scene.getGeo()` | + +## GeoReference Methods + +| Name | Parameters | Description | +| ----------- | ------------------------------------------------------------------ | -------------------------------------------------- | +| constructor | (origin?: GeoPoint, options?: GeoReferenceOptions) => GeoReference | Builds the reference and its tangent plane | +| toLocal | (p: GeoPoint, datum?: DatumType, target?: Vector3) => Vector3 | Geographic point -> local metres | +| toGeo | (v: Vector3, datum?: DatumType, target?: GeoPoint) => GeoPoint | Local metres -> geographic point | +| setPosition | (object: Object3D, p: GeoPoint, datum?: DatumType) => Object3D | Places an existing object at a geographic position | +| distance | (a: GeoPoint, b: GeoPoint, datum?: DatumType) => number | Planar distance in metres | + +```ts +import { GeoReference } from 'react-three-lite' +import type { GeoPoint } from 'react-three-lite' + +const origin: GeoPoint = { lng: 116.397, lat: 39.909 } +const geo = new GeoReference(origin, { crs: 'EPSG:4326', datum: 'WGS84' }) + +// Local metres relative to the origin (float64 internally). +const local = geo.toLocal({ lng: 116.4005, lat: 39.909 }) + +// Convert back; pass a per-call datum when mixing data sources. +const back = geo.toGeo(local) + +// GCJ02 (AMap/Tencent) input is normalized automatically. +const fromMars = geo.toLocal({ lng: 116.403, lat: 39.915 }, 'GCJ02') +``` + +## Camera Framing + +`fitCameraToPoints` is a single, coordinate-agnostic framing entry point. It +inspects the **shape** of each point instead of asking you whether the scene is +GIS or not: + +- A geographic point (`{ lng, lat, alt? }`) is projected to local metres through + `options.geo` (honouring `options.datum`). +- A local point (`THREE.Vector3` or `{ x, y, z? }`) is used as-is. + +Because every Scene always exposes a `GeoReference` (a no-op for ordinary +non-GIS scenes), the same call works in both worlds. Geographic and local points +must not be mixed in one call (it throws), and geographic points require +`options.geo`. An empty array is a no-op. + +```ts +import { fitCameraToPoints } from 'react-three-lite' +import { isGeoPoint } from 'react-three-lite' + +// GIS scene: {lng, lat} points are detected and projected automatically. +fitCameraToPoints(camera, [{ lng: 116.4, lat: 39.9 }], { + geo, + datum: 'WGS84', + controls +}) + +// Plain scene: local metre points are used directly, no geo needed. +fitCameraToPoints(camera, [new THREE.Vector3(-2, 0, -2), { x: 2, y: 0, z: 2 }]) + +isGeoPoint({ lng: 116.4, lat: 39.9 }) // true +isGeoPoint(new THREE.Vector3(0, 0, 0)) // false +``` + +| Function | Signature | Description | +| -------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------- | +| fitCameraToPoints | (camera, points: FitPoint[], options?: FitPointsOptions) => PerspectiveCamera | Unified framing; auto-detects geographic vs local points | +| isGeoPoint | (point: FitPoint) => boolean | Type guard: true when the point carries `lng`/`lat` | +| fitCameraToBox | (camera, box: Box3, options?) => PerspectiveCamera | Frame an explicit local-metre box | +| fitCameraToObject | (camera, object: Object3D \| Object3D[], options?) => PerspectiveCamera | Frame object world bounds | +| fitCameraToGeoPoints | (camera, points: GeoPoint[], geo, datum?, options?) => PerspectiveCamera | Legacy geographic-only wrapper around `fitCameraToPoints` | + +`FitPoint = GeoPoint | LocalPoint`, where `LocalPoint = THREE.Vector3 | { x, y, z? }`. + +`FitPointsOptions` adds `geo?: GeoReference` (required for geographic points) and +`datum?: DatumType` on top of the shared framing options: + +| Option | Type | Default | Description | +| ---------- | ------------------------------ | ------------------ | ----------------------------------------------------------------- | +| geo | GeoReference | — | Projects geographic points; always available via `components.geo` | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | GeoReference datum | Datum of the geographic inputs | +| elevation | number | `PI/4` | Viewing elevation in radians; `PI/2` is top-down | +| azimuth | number | `PI/4` | Camera bearing clockwise from north (-Z) | +| padding | number | `1.4` | Distance multiplier; >1 leaves margin | +| distance | number | computed | Override the computed distance (metres) | +| adjustClip | boolean | `true` | Derive near/far planes from the content scale | +| controls | OrbitControls | — | Sync the controls target to the bounds centre | + +### FitCamera + +`FitCamera` is a drop-in replacement for the camera you already pass to +``. It extends `THREE.PerspectiveCamera` with the same +constructor parameters and keeps a plain native `position`, so the default +camera creation and the `camera` prop are unchanged — a `FitCamera` is simply an +optional camera you can supply. Positioning is identical to every other object: +ask the scene to place it. + +```ts +import { useRef } from 'react' +import * as THREE from 'three' +import { Scene, FitCamera } from 'react-three-lite' +import type { SceneComponents } from 'react-three-lite' + +function App() { + const cameraRef = useRef(null) + + if (!cameraRef.current) cameraRef.current = new FitCamera() + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { controls, geo } = components + const camera = cameraRef.current + if (!camera || !geo) return + + camera.bind(geo, controls) + // The same scene helper that anchors a mesh positions the camera; the + // tuple is projected through the scene's GeoReference automatically. + scene.setPosition(camera, [116.397428, 39.90923, 120]) + controls?.target.set(0, 0, 0) + controls?.update() + } + + return +} +``` + +`camera.position` stays an ordinary `THREE.Vector3`, so all vector math used by +OrbitControls (`copy`, `add`, `sub`, `addScaledVector`, …) and the local-metre +`position.set(x, y, z)` behave exactly as before — there is nothing GIS-specific +to learn for non-GIS scenes. Projection happens only when you explicitly route +a geographic point through `scene.setPosition`. + +For automatic framing, the camera also owns the framing operations above, so you +no longer have to thread `camera`/`geo`/`controls` through every call. Every +method returns `this` for chaining. + +```ts +// Geographic points use the bound geo (a per-call datum/geo still wins). +camera.fitToPoints( + [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 } + ], + { datum: 'WGS84', azimuth: Math.PI } +) + +// Local metre points, objects and explicit boxes need no binding. +camera.fitToPoints([new THREE.Vector3(-2, 0, -2), { x: 2, y: 0, z: 2 }]) +camera.fitToObject(mesh, { padding: 1.2 }) +camera.fitToObject([mesh1, mesh2]) +camera.fitToBox(new THREE.Box3().setFromObject(group)) +``` + +The constructor mirrors `THREE.PerspectiveCamera` and defaults to +`(fov = 75, aspect = 1, near = 0.1, far = 1000)`, positioned at `(0, 0, 1)` like +the Scene's default camera; when the camera is owned by a Scene, the resize +handler syncs `aspect` to the container on mount and whenever it is resized. + +| Method | Signature | Description | +| ----------- | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| constructor | (fov?: number, aspect?: number, near?: number, far?: number) => FitCamera | Same parameters as `THREE.PerspectiveCamera` | +| bind | (geo?: GeoReference \| null, controls?: OrbitControls \| null) => this | Bind references for later `fitTo*` calls. Only passed arguments are updated, pass `null` to clear | +| fitToPoints | (points: FitPoint[], options?: FitCameraOptions) => this | Frame points; geographic `{lng, lat}` vs local points is auto-detected | +| fitToObject | (object: Object3D \| Object3D[], options?: CameraFitOptions) => this | Frame the world-space bounds of one or more objects | +| fitToBox | (box: Box3, options?: CameraFitOptions) => this | Frame an explicit local-metre box | + +`FitCameraOptions` is the shared framing option set with optional `geo` and +`datum`, so any field may be supplied per call and overrides the bound value; +per-call `controls` likewise overrides the bound controls. + +## Datum Helpers + +Chinese map services use encrypted datums; mixing them with raw GPS (WGS84) +data without conversion causes offsets of 50–600 metres inside China. + +| Function | Signature | Description | +| --------------------------- | --------------------------------------------------------- | --------------------------- | +| toWGS84 | (p: GeoPoint, from: DatumType) => GeoPoint | Convert GCJ02/BD09 to WGS84 | +| fromWGS84 | (p: GeoPoint, to: DatumType) => GeoPoint | Convert WGS84 to GCJ02/BD09 | +| convertDatum | (p: GeoPoint, from: DatumType, to: DatumType) => GeoPoint | Convert between any datums | +| wgs84ToGcj02 / gcj02ToWgs84 | (p: GeoPoint) => GeoPoint | Direct WGS84 `<->` GCJ02 | +| gcj02ToBd09 / bd09ToGcj02 | (p: GeoPoint) => GeoPoint | Direct GCJ02 `<->` BD09 | + +The GCJ02 -> WGS84 inverse uses fixed-point iteration; the BD09 closed-form +inverse has an inherent residual of a few centimetres. + +## Projection Helpers + +| Function | Description | +| ------------------------------ | -------------------------------------------------- | +| mercatorProject(p) | EPSG:4326 degrees -> EPSG:3857 Web Mercator metres | +| mercatorUnproject(v) | EPSG:3857 metres -> EPSG:4326 degrees | +| createLocalPlane(code, origin) | Builds a recentered `LocalPlane` for either CRS | diff --git a/docs/guide/gis/geojson-layer.md b/docs/guide/gis/geojson-layer.md new file mode 100644 index 0000000..99139a8 --- /dev/null +++ b/docs/guide/gis/geojson-layer.md @@ -0,0 +1,237 @@ +--- +id: geojson-layer +lang: en-US +title: GeoJsonLayer +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/GeoJsonLayer' + +## Type + +Component + +## Default Usage + +`GeoJsonLayer` renders [GeoJSON](https://geojson.org/) data (`FeatureCollection`, +`Feature` or a bare geometry) inside the Scene's `GeoReference`. Supported +geometry types are `Point`, `MultiPoint`, `LineString`, `MultiLineString`, +`Polygon` and `MultiPolygon`, including polygon holes. + +- Points render as diamond markers using one merged `LineSegments` draw call. +- Lines render as `LineSegments` rings. +- Polygons render as flat ground fills (`ShapeGeometry`) or extruded blocks + (`ExtrudeGeometry`) when `extrudeHeight > 0`. +- Returning `null` from `featureStyle` skips a feature entirely. + +Every example below can be viewed with either the **WebGPU** (default) or the +**WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoJsonLayer } from 'react-three-lite' + import type { GeoJsonData, GeoJsonStyle, SceneComponents } from 'react-three-lite' + + const ORIGIN = { lng: 116.397, lat: 39.909 } + + const data: GeoJsonData = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { kind: 'park' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.395, 39.9095], + [116.399, 39.9095], + [116.399, 39.9115], + [116.395, 39.9115], + [116.395, 39.9095] + ] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [116.397, 39.909] + } + } + ] + } + + const COLORS: Record = { + park: { fillColor: 0x4caf50, strokeColor: 0xb9f6ca, opacity: 0.7 } + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1200, + divisions: 24, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint looks north over the dataset; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + } + + return ( + + { + const kind = feature.properties?.kind + return typeof kind === 'string' ? COLORS[kind] : undefined + }} + /> + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoJsonLayer } from 'react-three-lite' + import type { GeoJsonData, GeoJsonStyle, SceneComponents } from 'react-three-lite' + + const ORIGIN = { lng: 116.397, lat: 39.909 } + + const data: GeoJsonData = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { kind: 'park' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.395, 39.9095], + [116.399, 39.9095], + [116.399, 39.9115], + [116.395, 39.9115], + [116.395, 39.9095] + ] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [116.397, 39.909] + } + } + ] + } + + const COLORS: Record = { + park: { fillColor: 0x4caf50, strokeColor: 0xb9f6ca, opacity: 0.7 } + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1200, + divisions: 24, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint looks north over the dataset; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + } + + return ( + + { + const kind = feature.properties?.kind + return typeof kind === 'string' ? COLORS[kind] : undefined + }} + /> + + ) + } + ``` + + + +## GCJ02 / BD09 Data + +When the data itself is encrypted (the typical case for tiles or data exported +from Chinese map services), pass `datum` to the layer or set it once on the +Scene. Coordinates are normalized before projection. + +```tsx + +``` + +## Props + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| data | GeoJsonData | — | A FeatureCollection, Feature or bare geometry object | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | Scene datum | Datum of the feature coordinates | +| style | GeoJsonStyle | see below | Base style for all features | +| featureStyle | (feature: GeoJsonFeature) => GeoJsonStyle \| null \| undefined | — | Per-feature override; `null` skips the feature | +| onReady | (group: THREE.Group) => void | — | Fired when the layer has been built and added | +| scene | THREE.Scene | context scene | Explicit scene for non-JSX usage | + +## GeoJsonStyle + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| fillColor | ColorRepresentation | 0x4a90d9 | Polygon fill color | +| strokeColor | ColorRepresentation | 0x9fd0ff | Polygon outline and line string color | +| pointColor | ColorRepresentation | 0xffd166 | Point marker color | +| pointSize | number | 20 | Point marker size in world metres | +| extrudeHeight | number | 0 | Polygon extrusion height in metres; 0 renders flat | +| opacity | number | 0.85 | Fill opacity | + +## Events + +| Name | Parameters | Description | +|------|------------|-------------| +| onReady | (group: THREE.Group) => void | Fired once after the layer group is added to the scene | diff --git a/docs/guide/gis/tile-layer.md b/docs/guide/gis/tile-layer.md new file mode 100644 index 0000000..d184977 --- /dev/null +++ b/docs/guide/gis/tile-layer.md @@ -0,0 +1,206 @@ +--- +id: tile-layer +lang: en-US +title: Tile Layer +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/TileLayer' + +## Type + +Component + +## Default Usage + +`TileLayer` projects standard XYZ / slippy-map raster tiles (256 px, Web +Mercator, the scheme used by OpenStreetMap, AMap and Tianditu) onto the flat +ground plane of the Scene's `GeoReference`. + +- Pass the tile source as a `url` template with `{z}`, `{x}`, `{y}` and an + optional `{s}` subdomain placeholder. +- By default the layer follows the camera: the visible ground rectangle (the + camera frustum projected onto the ground plane) is tiled at the given + `zoom`, so the basemap always covers the whole viewport, and tiles are + loaded/disposed automatically as you pan, zoom or orbit. A `padding` ring + of extra tiles keeps the map covered while moving. +- For a fixed basemap instead, pass the geographic corners it must cover as + `points` (with `followCamera={false}`); every tile intersecting their + bounding rectangle is requested once. +- Tile images load lazily and fade in as they arrive; tiles use a plain + `MeshBasicMaterial`, so the layer works with both the WebGPU and WebGL + renderer. +- A `GeoObject` anchored at a WGS84 (GPS standard) coordinate drops a pin + marker on the map; its `onReady` callback adds a cone mesh to the internal + Group, which is disposed on unmount. + +The tile imagery's datum must match the `datum` prop (and, in static mode, +the datum of the coverage points). OSM imagery is indexed in WGS84, so the +Scene's default datum is enough. + +Every example below can be viewed with either the **WebGPU** (default) or the +**WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, TileLayer, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + // WGS84 (GPS standard) point near Suzhou; used as both the local origin and the pin anchor. + const PIN: GeoPoint = { lng: 120.60682, lat: 31.330898 } + const ORIGIN = PIN + + // OpenStreetMap standard raster tiles are indexed in WGS84 coordinates. + const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + const TILE_ZOOM = 15 + + // GeoObject creates an internal Group and disposes its meshes on unmount. + const decoratePin = (anchor: THREE.Object3D) => { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xff3b30 }) + ) + // Lift the cone so its base sits on the ground. + cone.position.y = 60 + anchor.add(cone) + } + + function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint keeps the raster map readable like a + // conventional map; looking north from due south keeps it north-up. + scene.setPosition(camera, { x: 0, y: 600, z: 850 }) + camera.lookAt(0, 0, 0) + } + + return ( + + + + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, TileLayer, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + // WGS84 (GPS standard) point near Suzhou; used as both the local origin and the pin anchor. + const PIN: GeoPoint = { lng: 120.60682, lat: 31.330898 } + const ORIGIN = PIN + + // OpenStreetMap standard raster tiles are indexed in WGS84 coordinates. + const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + const TILE_ZOOM = 15 + + // GeoObject creates an internal Group and disposes its meshes on unmount. + const decoratePin = (anchor: THREE.Object3D) => { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xff3b30 }) + ) + // Lift the cone so its base sits on the ground. + cone.position.y = 60 + anchor.add(cone) + } + + function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint keeps the raster map readable like a + // conventional map; looking north from due south keeps it north-up. + scene.setPosition(camera, { x: 0, y: 600, z: 850 }) + camera.lookAt(0, 0, 0) + } + + return ( + + + + + ) + } + ``` + + + +## GCJ02 Imagery + +Chinese map services such as AMap (Gaode) and Tencent publish tiles in GCJ02 +coordinates. Set `datum="GCJ02"` (and, in static `points` mode, pass coverage +points expressed in GCJ02). Tile selection and corner placement then share +the same datum, so the non-linear GCJ02 shift is absorbed per tile corner and +adjacent tiles stay seamless. The `{s}` placeholder can be customized with +`subdomains`. + +```tsx +const AMAP_URL = + 'https://webrst.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}' + + +``` + +## Props + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| url | string | — | Tile URL template with `{z}`, `{x}`, `{y}` and optional `{s}` | +| zoom | number | — | Slippy-map zoom level (non-negative integer) | +| followCamera | boolean | true | Tile the visible ground rectangle every time the camera moves; when false, `points` defines a fixed coverage | +| points | GeoPoint[] | — | Static coverage corners in `datum`, used when `followCamera` is false | +| padding | number | 1 | Extra tile rings loaded beyond the visible rectangle so the map stays covered while panning | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | Scene datum | Datum of the tile imagery (and of `points` in static mode) | +| subdomains | string[] | `['a','b','c']` | Values cycling through the `{s}` placeholder | +| opacity | number | 1 | Opacity of the tile images | +| y | number | 0.05 | Height in local metres at which the ground quads are laid | +| fog | boolean | true | Distance fog anchored to the edge of the tiled coverage plus a background-tinted backing plane, so the map dissolves into the background at oblique angles instead of ending in a hard rectangle; the anchor re-fits as the camera zooms or orbits | +| groundColor | number \| string | Scene `bgColor`, else `#1a1a2e` | Colour of the distance fog and backing plane; set it to the Scene `bgColor` when using a background image | +| maxTiles | number | 256 | Cap on retained tiles; camera-following keeps the nearest ones, static mode throws when exceeded | +| maxPolarAngle | number | `PI/3` (60°) | Largest OrbitControls polar angle (radians from +y) allowed while following the camera; the same 60-degree camera-pitch ceiling MapLibre uses on flat maps, past which planar ground tiles stretch into blurred ribbons and the tiled coverage can no longer stay ahead of the horizon. A stricter existing limit is kept and the original value is restored on unmount | +| onReady | (group: THREE.Group) => void | — | Fired after all tile meshes were created | +| onTileError | (index: TileIndex, url: string) => void | — | Fired when one tile image fails to download; the tile stays hidden | +| scene | THREE.Scene | context scene | Explicit scene for non-JSX usage | + +## Tile Math Utilities + +The underlying slippy-map functions are exported for custom tile workflows. + +| Name | Parameters | Description | +|------|------------|-------------| +| TILE_SIZE | — | Edge length of a standard tile in pixels (256) | +| lngLatToTile | (lng: number, lat: number, zoom: number) => TileIndex | Tile column/row covering a coordinate; longitude wraps at the antimeridian and latitude is clamped to ±85.05112878 | +| tileToLngLatBounds | (index: TileIndex) => TileBounds | West/north/east/south bounds of a tile in degrees | +| tileToCorners | (index: TileIndex) => TileCorners | NW/NE/SE/SW corner coordinates of a tile | +| selectTiles | (points: GeoPoint[], zoom: number) => TileIndex[] | Every tile intersecting the bounding rectangle of the points | +| selectTilesInBounds | (west: number, south: number, east: number, north: number, zoom: number, padding?: number) => TileIndex[] | Every tile intersecting a west/south/east/north rectangle, expanded by `padding` rings; antimeridian-safe | +| tileUrl | (template: string, index: TileIndex, subdomains?: string[]) => string | Expands the `{s}`/`{z}`/`{x}`/`{y}` placeholders | + +## Events + +| Name | Parameters | Description | +|------|------------|-------------| +| onReady | (group: THREE.Group) => void | Fired once after the layer group is added to the scene | +| onTileError | (index: TileIndex, url: string) => void | Fired when one tile image fails to download | diff --git a/docs/i18n/en/docusaurus-plugin-content-docs/current.json b/docs/i18n/en/docusaurus-plugin-content-docs/current.json index 96401df..8a97429 100644 --- a/docs/i18n/en/docusaurus-plugin-content-docs/current.json +++ b/docs/i18n/en/docusaurus-plugin-content-docs/current.json @@ -14,5 +14,25 @@ "sidebar.guideSidebar.category.Effect": { "message": "Effect", "description": "The label for category 'Effect' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.category.GIS": { + "message": "GIS", + "description": "The label for category 'GIS' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/geo-reference": { + "message": "Geographic Reference", + "description": "The label for doc 'gis/geo-reference' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/geo-object": { + "message": "GeoObject", + "description": "The label for doc 'gis/geo-object' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/geojson-layer": { + "message": "GeoJsonLayer", + "description": "The label for doc 'gis/geojson-layer' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/tile-layer": { + "message": "Tile Layer", + "description": "The label for doc 'gis/tile-layer' in sidebar 'guideSidebar'" } } \ No newline at end of file diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json index fb85e29..bad83ce 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json @@ -15,6 +15,26 @@ "message": "效果", "description": "The label for category 'Effect' in sidebar 'guideSidebar'" }, + "sidebar.guideSidebar.category.GIS": { + "message": "GIS", + "description": "The label for category 'GIS' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/geo-reference": { + "message": "地理参考系", + "description": "The label for doc 'gis/geo-reference' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/geo-object": { + "message": "GeoObject 地理对象", + "description": "The label for doc 'gis/geo-object' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/geojson-layer": { + "message": "GeoJsonLayer 地理图层", + "description": "The label for doc 'gis/geojson-layer' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.gis/tile-layer": { + "message": "TileLayer 瓦片底图", + "description": "The label for doc 'gis/tile-layer' in sidebar 'guideSidebar'" + }, "sidebar.guideSidebar.doc.getting-started": { "message": "快速开始", "description": "The label for doc 'getting-started' in sidebar 'guideSidebar'" diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-object.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-object.md new file mode 100644 index 0000000..ccc66ae --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-object.md @@ -0,0 +1,170 @@ +--- +lang: zh-CN +title: GeoObject 地理对象 +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/GeoObject' + +## 类型 + +组件 + +## 默认用法 + +`GeoObject` 将一个 `Object3D` 锚定到地理坐标上。不传 `object` 时,组件会创建一个空的 +`THREE.Group` 并通过 `onReady` 交给你,你可以在其中添加标记、标签或已加载的模型。更新 +`coordinate` 或 `datum` 时会直接移动物体,不会重新构建。 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + // Scene 原点使用 WGS84。 + const ORIGIN: GeoPoint = { lng: 116.391, lat: 39.907 } + + // GCJ02 坐标,例如高德/腾讯服务返回的坐标。 + const MARKERS: GeoPoint[] = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } + ] + + function decorate(object: THREE.Object3D) { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xffd166 }) + ) + cone.position.y = 60 + object.add(cone) + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1400, + divisions: 28, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the markers; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 750, z: 1000 }) + camera.lookAt(0, 0, 0) + } + + return ( + + {MARKERS.map((coordinate, index) => ( + + ))} + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + const ORIGIN: GeoPoint = { lng: 116.391, lat: 39.907 } + const MARKERS: GeoPoint[] = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } + ] + + function decorate(object: THREE.Object3D) { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xffd166 }) + ) + cone.position.y = 60 + object.add(cone) + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1400, + divisions: 28, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the markers; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 750, z: 1000 }) + camera.lookAt(0, 0, 0) + } + + return ( + + {MARKERS.map((coordinate, index) => ( + + ))} + + ) + } + ``` + + + +## 锚定已有对象 + +传入自己的 `Object3D` 时,对象的归属仍属于调用方:组件会把它重新挂到 Scene +下,卸载时再恢复到原来的父节点。 + +```tsx +const model = new THREE.Group() + + +``` + +## Props + +| 名称 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| coordinate | GeoPoint | — | 地理锚点,采用 `datum` 声明的基准面 | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | Scene 的 datum | `coordinate` 所使用的基准面 | +| object | THREE.Object3D | 自动创建 Group | 要锚定的已有对象 | +| onReady | (object: Object3D) => void | — | 对象就绪后回调一次 | +| scene | THREE.Scene | 上下文中的 scene | 非 JSX 用法下显式指定场景 | + +## 事件 + +| 名称 | 参数 | 描述 | +|------|------|------| +| onReady | (object: THREE.Object3D) => void | 对象被添加到场景并完成定位后触发 | diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md new file mode 100644 index 0000000..9bdf01c --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md @@ -0,0 +1,417 @@ +--- +lang: zh-CN +title: 地理参考系 +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/GeoReference' + +## 类型 + +类 + Scene 配置项 + +## 概念 + +`GeoReference` 将坐标参考系、大地基准面和轴约定绑定到一个固定的**局部原点**。转换链路如下: + +```text +地理坐标 (lng, lat, alt) + -> 基准面归一化到 WGS84 + -> 局部切平面(float64 米制) + -> 围绕局部原点重新中心化 + -> ENU -> three.js 轴映射(东:+X,北:-Z,上:+Y) + -> THREE.Vector3(float32) +``` + +局部原点保证了场景坐标始终很小。Web Mercator 的绝对坐标约为 1.3e7 米,直接使用 three.js 的 float32 坐标在城市场景中会产生明显抖动;重新中心化后,所有物体都位于距 `(0, 0, 0)` 几公里以内。 + +每个 Scene 都存在一个 `GeoReference`。不传入任何 GIS 配置时,它使用以 `(0, 0)` 为中心的 WGS84/等距圆柱默认值,对普通的非地理场景是完全无副作用的空操作,因此该功能是完全可选的。 + +## 默认用法 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + const ORIGIN: GeoPoint = { lng: 116.397, lat: 39.909 } + const PLACES: { point: GeoPoint; color: number }[] = [ + { point: ORIGIN, color: 0xffd166 }, + { point: { lng: 116.4005, lat: 39.909 }, color: 0x4fc3f7 }, + { point: { lng: 116.397, lat: 39.9117 }, color: 0x81c784 }, + { point: { lng: 116.3935, lat: 39.9063 }, color: 0xe57373 } + ] + + function App() { + const objectsRef = useRef([]) + + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1200, + divisions: 24, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the landmarks; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + + PLACES.forEach(({ point, color }) => { + const radius = 35 + const geometry = new THREE.SphereGeometry(radius, 24, 16) + const material = new THREE.MeshBasicMaterial({ color }) + const marker = new THREE.Mesh(geometry, material) + // 参考系已绑定在 scene 上,由 scene 自己完成投影。 + scene.setPosition(marker, point) + // Rest the sphere on the ground plane instead of burying its lower half. + marker.position.y = radius + scene.add(marker) + objectsRef.current.push(marker) + }) + } + + useEffect(() => { + return () => { + objectsRef.current.forEach((object) => { + object.removeFromParent() + const mesh = object as THREE.Mesh + mesh.geometry?.dispose() + ;(mesh.material as THREE.Material | undefined)?.dispose() + }) + objectsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + const ORIGIN: GeoPoint = { lng: 116.397, lat: 39.909 } + const PLACES: { point: GeoPoint; color: number }[] = [ + { point: ORIGIN, color: 0xffd166 }, + { point: { lng: 116.4005, lat: 39.909 }, color: 0x4fc3f7 } + ] + + function App() { + const objectsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the landmarks; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + + PLACES.forEach(({ point, color }) => { + const radius = 35 + const geometry = new THREE.SphereGeometry(radius, 24, 16) + const material = new THREE.MeshBasicMaterial({ color }) + const marker = new THREE.Mesh(geometry, material) + // 参考系已绑定在 scene 上,由 scene 自己完成投影。 + scene.setPosition(marker, point) + // Rest the sphere on the ground plane instead of burying its lower half. + marker.position.y = radius + scene.add(marker) + objectsRef.current.push(marker) + }) + } + + useEffect(() => { + return () => { + objectsRef.current.forEach((object) => { + object.removeFromParent() + const mesh = object as THREE.Mesh + mesh.geometry?.dispose() + ;(mesh.material as THREE.Material | undefined)?.dispose() + }) + objectsRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + +## Scene 配置项 + +| 属性 | 类型 | 默认值 | 描述 | +| ------ | ------------------------------ | ------------------------------------- | ------------------------------------------------------------ | +| origin | GeoPoint | `{ lng: 0, lat: 0, alt: 0 }` | 局部切平面原点,所有场景坐标都相对于它 | +| crs | `'EPSG:4326' \| 'EPSG:3857'` | `'EPSG:4326'` | 平面模型:等距圆柱切平面或重新中心化的 Web Mercator | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | `'WGS84'` | 传入场景的坐标所使用的基准面 | +| axes | AxesMapping | `{ east: 'x', north: '-z', up: 'y' }` | 将 ENU 基映射到带符号的 three.js 轴 | +| geo | GeoReference | — | 直接传入预先构建的参考系,替代 `origin`/`crs`/`datum`/`axes` | + +Scene 构建出的参考系可在 `onCreated(scene, components)` 回调中通过 +`components.geo` 获取,也可通过 `useScene().geo` 获取。它同时会绑定到 scene +自身,因此大多数代码完全不需要显式提到 `geo`。 + +## 物体定位 + +每个 Scene 都会把自己的 `GeoReference` 绑定到 scene 实例上,并安装一个统一的 +定位方法 `scene.setPosition(object, position, datum?)`。由于 scene 已经持有参考系, +定位时只需给出坐标本身,无需再把参考系传来传去;mesh、group 和 camera 都是 +`THREE.Object3D`,所以同一个调用写法可以定位任意对象。 + +`position` 接受所有常用形态(`ScenePosition`): + +| 坐标形态 | 含义 | +| --- | --- | +| `{ lng, lat, alt? }` | 地理点,经 scene 参考系投影 | +| `[lng, lat]` | 地理点元组,高度默认 `0` | +| `[lng, lat, alt]` | 地理点元组 | +| `THREE.Vector3` | 局部米坐标,原样使用、不投影 | +| `{ x, y, z? }` | 局部米坐标对象,`z` 默认 `0` | + +```ts +const handleCreated = (scene: THREE.Scene) => { + // 下面四种都是地理坐标,统一经由 scene 自身的参考系投影。 + scene.setPosition(mesh, { lng: 116.397, lat: 39.909 }) + scene.setPosition(mesh, { lng: 116.397, lat: 39.909, alt: 35 }) + scene.setPosition(mesh, [116.397, 39.909]) + scene.setPosition(mesh, [116.397, 39.909, 35]) + + // GCJ02/BD09 输入可按次传入 datum 覆盖场景的 datum。 + scene.setPosition(mesh, [116.403, 39.915], 'GCJ02') + + // 相机与 mesh 使用完全相同的调用。 + scene.setPosition(camera, [116.397428, 39.90923, 120]) + + // 局部米坐标原样使用,不做投影。 + scene.setPosition(helper, new THREE.Vector3(10, 0, -5)) + scene.setPosition(helper, { x: 10, y: 0, z: -5 }) +} +``` + +方法会返回传入的对象,支持链式调用。自行 `new THREE.Scene()` 创建的场景没有该 +方法;可调用一次 `bindSceneGeo(scene, geo)` 安装,或通过 `getSceneGeo(scene)` +读取参考系。如果场景已安装方法但没有绑定参考系,地理元组/对象会回退为本地坐标, +把 `lng/lat/alt` 直接写到 `x/y/z`,因此该调用在任何场景下都是安全的。 + +| 辅助函数 | 签名 | 描述 | +| --- | --- | --- | +| scene.setPosition | (object: Object3D, position: ScenePosition, datum?: DatumType) => Object3D | 定位任意对象;地理输入自动投影,局部米坐标原样使用 | +| scene.getGeo | () => GeoReference \| undefined | 读取绑定在 scene 上的参考系 | +| bindSceneGeo | (scene: Scene, geo?: GeoReference) => Scene | 在 scene 上安装定位方法,并可选绑定参考系 | +| getSceneGeo | (scene: Scene) => GeoReference \| undefined | `scene.getGeo()` 的函数形式 | + +## GeoReference 方法 + +| 名称 | 参数 | 描述 | +| ----------- | ------------------------------------------------------------------ | ---------------------------- | +| constructor | (origin?: GeoPoint, options?: GeoReferenceOptions) => GeoReference | 构建参考系及其切平面 | +| toLocal | (p: GeoPoint, datum?: DatumType, target?: Vector3) => Vector3 | 地理坐标 -> 局部米制坐标 | +| toGeo | (v: Vector3, datum?: DatumType, target?: GeoPoint) => GeoPoint | 局部米制坐标 -> 地理坐标 | +| setPosition | (object: Object3D, p: GeoPoint, datum?: DatumType) => Object3D | 将已有物体放置到指定地理位置 | +| distance | (a: GeoPoint, b: GeoPoint, datum?: DatumType) => number | 平面距离(米) | + +```ts +import { GeoReference } from 'react-three-lite' +import type { GeoPoint } from 'react-three-lite' + +const origin: GeoPoint = { lng: 116.397, lat: 39.909 } +const geo = new GeoReference(origin, { crs: 'EPSG:4326', datum: 'WGS84' }) + +// 相对于原点的局部米制坐标(内部使用 float64 计算)。 +const local = geo.toLocal({ lng: 116.4005, lat: 39.909 }) + +// 反向转换;混合多种数据源时可按次传入 datum。 +const back = geo.toGeo(local) + +// GCJ02(高德/腾讯)输入会被自动归一化。 +const fromMars = geo.toLocal({ lng: 116.403, lat: 39.915 }, 'GCJ02') +``` + +## 相机取景 + +`fitCameraToPoints` 是一个与坐标系无关的统一取景点。它通过检查每个点的 +**形状**来判断,而不需要你说明场景是不是 GIS: + +- 地理点(`{ lng, lat, alt? }`)会通过 `options.geo` 投影到局部米坐标(并遵循 + `options.datum`)。 +- 本地点(`THREE.Vector3` 或 `{ x, y, z? }`)则原样使用。 + +由于每个 Scene 都始终提供一个 `GeoReference`(对普通非地理场景是空操作),同一 +次调用在两种世界中都能工作。地理点与本地点不能在同一次调用中混用(会抛错), +且地理点必须提供 `options.geo`。空数组为空操作。 + +```ts +import { fitCameraToPoints } from 'react-three-lite' +import { isGeoPoint } from 'react-three-lite' + +// GIS 场景:自动识别 {lng, lat} 点并完成投影。 +fitCameraToPoints(camera, [{ lng: 116.4, lat: 39.9 }], { + geo, + datum: 'WGS84', + controls +}) + +// 普通场景:直接使用局部米制点,无需 geo。 +fitCameraToPoints(camera, [new THREE.Vector3(-2, 0, -2), { x: 2, y: 0, z: 2 }]) + +isGeoPoint({ lng: 116.4, lat: 39.9 }) // true +isGeoPoint(new THREE.Vector3(0, 0, 0)) // false +``` + +| 函数 | 签名 | 描述 | +| -------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------- | +| fitCameraToPoints | (camera, points: FitPoint[], options?: FitPointsOptions) => PerspectiveCamera | 统一取景,自动识别地理点/本地点 | +| isGeoPoint | (point: FitPoint) => boolean | 类型守卫:点带有 `lng`/`lat` 时为 true | +| fitCameraToBox | (camera, box: Box3, options?) => PerspectiveCamera | 对显式的局部米制包围盒取景 | +| fitCameraToObject | (camera, object: Object3D \| Object3D[], options?) => PerspectiveCamera | 对物体的世界包围盒取景 | +| fitCameraToGeoPoints | (camera, points: GeoPoint[], geo, datum?, options?) => PerspectiveCamera | 仅地理点的旧接口,内部封装 `fitCameraToPoints` | + +`FitPoint = GeoPoint | LocalPoint`,其中 `LocalPoint = THREE.Vector3 | { x, y, z? }`。 + +`FitPointsOptions` 在通用取景选项之上额外增加了 `geo?: GeoReference`(地理点必填) +和 `datum?: DatumType`: + +| 选项 | 类型 | 默认值 | 描述 | +| ---------- | ------------------------------ | --------------------- | -------------------------------------------- | +| geo | GeoReference | — | 用于投影地理点;可通过 `components.geo` 获取 | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | GeoReference 的 datum | 地理输入所使用的基准面 | +| elevation | number | `PI/4` | 俯仰角(弧度);`PI/2` 为正俯视 | +| azimuth | number | `PI/4` | 相对正北(-Z)顺时针的相机方位角 | +| padding | number | `1.4` | 距离倍数;>1 留出边距 | +| distance | number | 自动计算 | 覆盖计算出的距离(米) | +| adjustClip | boolean | `true` | 根据内容尺度推导近/远裁剪面 | +| controls | OrbitControls | — | 将控制器目标同步到包围盒中心 | + +### FitCamera + +`FitCamera` 可以直接替换你原本通过 `` 传入的相机。它继承自 +`THREE.PerspectiveCamera`,构造参数完全相同,`position` 也是普通的原生向量,因此 Scene +的默认相机创建逻辑和 `camera` 传参保持不变——`FitCamera` 只是一个可选的、由你自行传入的 +相机。定位方式与其他任何对象完全一致:交给 scene 来放置。 + +```ts +import { useRef } from 'react' +import * as THREE from 'three' +import { Scene, FitCamera } from 'react-three-lite' +import type { SceneComponents } from 'react-three-lite' + +function App() { + const cameraRef = useRef(null) + + if (!cameraRef.current) cameraRef.current = new FitCamera() + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { controls, geo } = components + const camera = cameraRef.current + if (!camera || !geo) return + + camera.bind(geo, controls) + // 与锚定 mesh 完全相同的 scene 调用,元组会自动经 scene 参考系投影。 + scene.setPosition(camera, [116.397428, 39.90923, 120]) + controls?.target.set(0, 0, 0) + controls?.update() + } + + return +} +``` + +`camera.position` 始终是普通的 `THREE.Vector3`,OrbitControls 用到的向量运算 +(`copy`、`add`、`sub`、`addScaledVector` 等)以及局部米坐标的 +`position.set(x, y, z)` 行为都与之前完全一致——非 GIS 场景没有任何额外概念需要学习。 +只有当你显式地把地理点交给 `scene.setPosition` 时才会发生投影。 + +需要自动取景时,相机也可以直接持有上文的取景操作,而不必每次都把 +`camera`/`geo`/`controls` 传一遍。所有方法都返回 `this`,支持链式调用。 + +```ts +// 地理点使用已绑定的 geo(单次调用仍可传入 datum/geo 覆盖)。 +camera.fitToPoints( + [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 } + ], + { datum: 'WGS84', azimuth: Math.PI } +) + +// 局部米制点、物体和显式包围盒无需绑定即可使用。 +camera.fitToPoints([new THREE.Vector3(-2, 0, -2), { x: 2, y: 0, z: 2 }]) +camera.fitToObject(mesh, { padding: 1.2 }) +camera.fitToObject([mesh1, mesh2]) +camera.fitToBox(new THREE.Box3().setFromObject(group)) +``` + +构造参数与 `THREE.PerspectiveCamera` 一致,默认为 +`(fov = 75, aspect = 1, near = 0.1, far = 1000)`,初始位置与 Scene 默认相机一样为 +`(0, 0, 1)`;当相机由 Scene 持有时,尺寸同步逻辑会在挂载时以及容器尺寸变化时把 `aspect` +同步为容器宽高比。 + +| 方法 | 签名 | 描述 | +| ----------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| constructor | (fov?: number, aspect?: number, near?: number, far?: number) => FitCamera | 参数与 `THREE.PerspectiveCamera` 相同 | +| bind | (geo?: GeoReference \| null, controls?: OrbitControls \| null) => this | 为后续 `fitTo*` 调用绑定引用。只更新显式传入的参数,传 `null` 可清除绑定 | +| fitToPoints | (points: FitPoint[], options?: FitCameraOptions) => this | 对点取景;自动判别地理点 `{lng, lat}` 与局部米制点 | +| fitToObject | (object: Object3D \| Object3D[], options?: CameraFitOptions) => this | 对一个或多个物体的世界空间包围盒取景 | +| fitToBox | (box: Box3, options?: CameraFitOptions) => this | 对显式的局部米制包围盒取景 | + +`FitCameraOptions` 在通用取景选项集之上增加了可选的 `geo` 和 `datum`,因此任意字段都可以在 +单次调用时传入并覆盖已绑定的值;单次调用传入的 `controls` 同样会覆盖已绑定的控制器。 + +## 基准面工具函数 + +国内地图服务使用加密基准面;若不转换就与原始 GPS(WGS84)数据混用,在中国境内会产生 50–600 米的偏移。 + +| 函数 | 签名 | 描述 | +| --------------------------- | --------------------------------------------------------- | ------------------------ | +| toWGS84 | (p: GeoPoint, from: DatumType) => GeoPoint | 将 GCJ02/BD09 转为 WGS84 | +| fromWGS84 | (p: GeoPoint, to: DatumType) => GeoPoint | 将 WGS84 转为 GCJ02/BD09 | +| convertDatum | (p: GeoPoint, from: DatumType, to: DatumType) => GeoPoint | 任意基准面之间互转 | +| wgs84ToGcj02 / gcj02ToWgs84 | (p: GeoPoint) => GeoPoint | WGS84 与 GCJ02 直接互转 | +| gcj02ToBd09 / bd09ToGcj02 | (p: GeoPoint) => GeoPoint | GCJ02 与 BD09 直接互转 | + +GCJ02 -> WGS84 的反解采用定点迭代;BD09 闭式反解存在几厘米的固有残差。 + +## 投影工具函数 + +| 函数 | 描述 | +| ------------------------------ | --------------------------------------------- | +| mercatorProject(p) | EPSG:4326 经纬度 -> EPSG:3857 Web Mercator 米 | +| mercatorUnproject(v) | EPSG:3857 米 -> EPSG:4326 经纬度 | +| createLocalPlane(code, origin) | 为任一 CRS 构建重新中心化的 `LocalPlane` | diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md new file mode 100644 index 0000000..9e4015e --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md @@ -0,0 +1,233 @@ +--- +lang: zh-CN +title: GeoJsonLayer 地理图层 +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/GeoJsonLayer' + +## 类型 + +组件 + +## 默认用法 + +`GeoJsonLayer` 在 Scene 的 `GeoReference` 中渲染 [GeoJSON](https://geojson.org/) +数据(`FeatureCollection`、`Feature` 或裸几何对象)。支持的几何类型包括 +`Point`、`MultiPoint`、`LineString`、`MultiLineString`、`Polygon` 和 +`MultiPolygon`,多边形支持洞(holes)。 + +- 点合并为一次 `LineSegments` 绘制调用,渲染为菱形标记。 +- 线以 `LineSegments` 环的方式渲染。 +- 多边形默认为贴地平面填充(`ShapeGeometry`),当 `extrudeHeight > 0` 时渲染为拉伸体(`ExtrudeGeometry`)。 +- `featureStyle` 返回 `null` 可完全跳过某个要素。 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoJsonLayer } from 'react-three-lite' + import type { GeoJsonData, GeoJsonStyle, SceneComponents } from 'react-three-lite' + + const ORIGIN = { lng: 116.397, lat: 39.909 } + + const data: GeoJsonData = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { kind: 'park' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.395, 39.9095], + [116.399, 39.9095], + [116.399, 39.9115], + [116.395, 39.9115], + [116.395, 39.9095] + ] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [116.397, 39.909] + } + } + ] + } + + const COLORS: Record = { + park: { fillColor: 0x4caf50, strokeColor: 0xb9f6ca, opacity: 0.7 } + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1200, + divisions: 24, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint looks north over the dataset; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + } + + return ( + + { + const kind = feature.properties?.kind + return typeof kind === 'string' ? COLORS[kind] : undefined + }} + /> + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, GeoJsonLayer } from 'react-three-lite' + import type { GeoJsonData, GeoJsonStyle, SceneComponents } from 'react-three-lite' + + const ORIGIN = { lng: 116.397, lat: 39.909 } + + const data: GeoJsonData = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { kind: 'park' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.395, 39.9095], + [116.399, 39.9095], + [116.399, 39.9115], + [116.395, 39.9115], + [116.395, 39.9095] + ] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [116.397, 39.909] + } + } + ] + } + + const COLORS: Record = { + park: { fillColor: 0x4caf50, strokeColor: 0xb9f6ca, opacity: 0.7 } + } + + function App() { + // The built-in grid is 20 m by default; a city-scale scene customises + // it through gridHelper options, and Scene owns its disposal. + const grid = { + size: 1200, + divisions: 24, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c + } + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint looks north over the dataset; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + } + + return ( + + { + const kind = feature.properties?.kind + return typeof kind === 'string' ? COLORS[kind] : undefined + }} + /> + + ) + } + ``` + + + +## GCJ02 / BD09 数据 + +当地理数据本身是加密坐标时(国内地图服务导出数据的常见情况),给图层传入 +`datum`,或者在 Scene 上统一配置一次即可。坐标会在投影前完成归一化。 + +```tsx + +``` + +## Props + +| 名称 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| data | GeoJsonData | — | FeatureCollection、Feature 或裸几何对象 | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | Scene 的 datum | 要素坐标所使用的基准面 | +| style | GeoJsonStyle | 见下表 | 所有要素的基础样式 | +| featureStyle | (feature: GeoJsonFeature) => GeoJsonStyle \| null \| undefined | — | 按要素覆盖样式;返回 `null` 跳过该要素 | +| onReady | (group: THREE.Group) => void | — | 图层构建完成后触发 | +| scene | THREE.Scene | 上下文中的 scene | 非 JSX 用法下显式指定场景 | + +## GeoJsonStyle + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| fillColor | ColorRepresentation | 0x4a90d9 | 多边形填充色 | +| strokeColor | ColorRepresentation | 0x9fd0ff | 多边形描边和线串颜色 | +| pointColor | ColorRepresentation | 0xffd166 | 点标记颜色 | +| pointSize | number | 20 | 点标记大小(世界坐标米) | +| extrudeHeight | number | 0 | 多边形拉伸高度(米);0 表示贴地平面 | +| opacity | number | 0.85 | 填充不透明度 | + +## 事件 + +| 名称 | 参数 | 描述 | +|------|------|------| +| onReady | (group: THREE.Group) => void | 图层组添加到场景后触发一次 | diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/tile-layer.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/tile-layer.md new file mode 100644 index 0000000..6fe9bbd --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/tile-layer.md @@ -0,0 +1,191 @@ +--- +lang: zh-CN +title: TileLayer 瓦片底图 +--- + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import App from '@site/src/components/gis/TileLayer' + +## 类型 + +组件 + +## 默认用法 + +`TileLayer` 将标准 XYZ / 滑窗地图栅格瓦片(256 像素、Web Mercator 投影, +即 OpenStreetMap、高德、天地图所使用的方案)投影到 Scene 的 `GeoReference` +地面平面上。 + +- `url` 传入瓦片地址模板,支持 `{z}`、`{x}`、`{y}` 以及可选的 `{s}` 子域占位符。 +- 默认情况下图层跟随相机:将可见地面矩形(相机视锥投影到地面平面)按给定 `zoom` 铺满瓦片,底图始终覆盖整个视口;平移、缩放或旋转相机时自动加载/卸载瓦片。`padding` 外扩瓦片圈保证移动过程中地图不露底。 +- 如需固定底图,可通过 `points` 传入需要覆盖的地理角点(并设置 `followCamera={false}`);组件会一次性请求与其包围矩形相交的所有瓦片。 +- 瓦片图片懒加载,到达后淡入;瓦片使用普通 `MeshBasicMaterial`,因此 WebGPU 与 WebGL 渲染器均可用。 +- 默认开启空气透视:锚定到瓦片覆盖边缘的距离雾,加上一块与背景同色的衬底平面,使瓦片区域在倾斜视角下自然融入背景,不露出生硬的矩形边界;缩放或环绕相机时雾的锚点会自动重新拟合。不需要时可设 `fog={false}`;背景为图片时可用 `groundColor` 指定雾与衬底的颜色。 +- 用锚定到 WGS84(GPS 标准)坐标的 `GeoObject` 在地图上放置图钉;其 `onReady` 回调向内部 Group 添加一个圆锥 mesh,卸载时自动销毁。 + +瓦片影像的 datum(基准面)必须与 `datum` 属性一致(静态模式下覆盖点亦同)。 +OSM 影像以 WGS84 索引,因此使用 Scene 默认 datum 即可。 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, TileLayer, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + // WGS84 (GPS standard) point near Suzhou; used as both the local origin and the pin anchor. + const PIN: GeoPoint = { lng: 120.60682, lat: 31.330898 } + const ORIGIN = PIN + + // OpenStreetMap standard raster tiles are indexed in WGS84 coordinates. + const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + const TILE_ZOOM = 15 + + // GeoObject creates an internal Group and disposes its meshes on unmount. + const decoratePin = (anchor: THREE.Object3D) => { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xff3b30 }) + ) + // Lift the cone so its base sits on the ground. + cone.position.y = 60 + anchor.add(cone) + } + + function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint keeps the raster map readable like a + // conventional map; looking north from due south keeps it north-up. + scene.setPosition(camera, { x: 0, y: 600, z: 850 }) + camera.lookAt(0, 0, 0) + } + + return ( + + + + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, TileLayer, GeoObject } from 'react-three-lite' + import type { GeoPoint, SceneComponents } from 'react-three-lite' + + // WGS84 (GPS standard) point near Suzhou; used as both the local origin and the pin anchor. + const PIN: GeoPoint = { lng: 120.60682, lat: 31.330898 } + const ORIGIN = PIN + + // OpenStreetMap standard raster tiles are indexed in WGS84 coordinates. + const TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + const TILE_ZOOM = 15 + + // GeoObject creates an internal Group and disposes its meshes on unmount. + const decoratePin = (anchor: THREE.Object3D) => { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xff3b30 }) + ) + // Lift the cone so its base sits on the ground. + cone.position.y = 60 + anchor.add(cone) + } + + function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint keeps the raster map readable like a + // conventional map; looking north from due south keeps it north-up. + scene.setPosition(camera, { x: 0, y: 600, z: 850 }) + camera.lookAt(0, 0, 0) + } + + return ( + + + + + ) + } + ``` + + + +## GCJ02 影像 + +高德、腾讯等国内地图服务发布的瓦片使用 GCJ02 坐标。请设置 `datum="GCJ02"` +(静态 `points` 模式下覆盖点也需以 GCJ02 表达)。此时选瓦与放瓦共享同一 +datum,非线性的 GCJ02 偏移会逐瓦片角点吸收,相邻瓦片保持无缝。`{s}` 占位符 +可通过 `subdomains` 自定义。 + +```tsx +const AMAP_URL = + 'https://webrst.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}' + + +``` + +## Props + +| 名称 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| url | string | — | 瓦片 URL 模板,含 `{z}`、`{x}`、`{y}` 及可选 `{s}` | +| zoom | number | — | 滑窗地图缩放级别(非负整数) | +| followCamera | boolean | true | 相机每次移动后重新铺满可见地面矩形;为 false 时由 `points` 定义固定覆盖范围 | +| points | GeoPoint[] | — | 静态覆盖角点,坐标使用 `datum`,在 `followCamera` 为 false 时生效 | +| padding | number | 1 | 在可见矩形之外多加载的瓦片圈数,保证平移过程中地图不露底 | +| datum | `'WGS84' \| 'GCJ02' \| 'BD09'` | Scene 的 datum | 瓦片影像的基准面(静态模式下覆盖点亦同) | +| subdomains | string[] | `['a','b','c']` | 轮流填充 `{s}` 占位符的值 | +| opacity | number | 1 | 瓦片图片不透明度 | +| y | number | 0.05 | 地面瓦片铺设高度(本地坐标米) | +| fog | boolean | true | 锚定到瓦片覆盖边缘的距离雾与背景同色衬底平面,使瓦片区域在倾斜视角下融入背景,不露出生硬的矩形边界;缩放或环绕时锚点自动重新拟合 | +| groundColor | number \| string | Scene 的 bgColor,否则 `#1a1a2e` | 距离雾与衬底平面的颜色;Scene 背景为图片时手动指定为与背景相近的颜色 | +| maxTiles | number | 256 | 保留瓦片数上限;相机跟随模式保留最近者,静态模式超出时抛错 | +| maxPolarAngle | number | `PI/3`(60°) | 相机跟随模式下允许的最大 OrbitControls 极角(与 +y 轴夹角,弧度);与 MapLibre 平面地图的 60° 相机俯仰上限一致,超过该角度平面瓦片会被拉伸成模糊条带且瓦片覆盖无法跑赢地平线。已存在的更严格限制不会被放宽,卸载时还原原值 | +| onReady | (group: THREE.Group) => void | — | 全部瓦片网格创建后触发 | +| onTileError | (index: TileIndex, url: string) => void | — | 某张瓦片下载失败时触发;该瓦片保持隐藏 | +| scene | THREE.Scene | 上下文中的 scene | 非 JSX 用法下显式指定场景 | + +## 瓦片数学工具 + +底层滑窗地图函数已导出,可用于自定义瓦片工作流。 + +| 名称 | 参数 | 描述 | +|------|------|------| +| TILE_SIZE | — | 标准瓦片边长(像素),值为 256 | +| lngLatToTile | (lng: number, lat: number, zoom: number) => TileIndex | 计算覆盖某坐标的瓦行列;经度跨反经线回绕,纬度钳制到 ±85.05112878 | +| tileToLngLatBounds | (index: TileIndex) => TileBounds | 计算瓦片的西/北/东/南边界(度) | +| tileToCorners | (index: TileIndex) => TileCorners | 计算瓦片的 NW/NE/SE/SW 四角坐标 | +| selectTiles | (points: GeoPoint[], zoom: number) => TileIndex[] | 返回与点集包围矩形相交的全部瓦片 | +| selectTilesInBounds | (west: number, south: number, east: number, north: number, zoom: number, padding?: number) => TileIndex[] | 返回与西/南/东/北矩形相交的全部瓦片,并按 `padding` 圈外扩;跨反经线安全 | +| tileUrl | (template: string, index: TileIndex, subdomains?: string[]) => string | 展开 `{s}`/`{z}`/`{x}`/`{y}` 占位符 | + +## 事件 + +| 名称 | 参数 | 描述 | +|------|------|------| +| onReady | (group: THREE.Group) => void | 图层组添加到场景后触发一次 | +| onTileError | (index: TileIndex, url: string) => void | 某张瓦片图片下载失败时触发 | diff --git a/docs/sidebars.js b/docs/sidebars.js index da34ee9..b91c471 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -29,6 +29,17 @@ const sidebars = { collapsible: true, items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/sweep-light', 'effects/light-gradient', 'effects/light-pillar'], }, + { + type: 'category', + label: 'GIS', + collapsible: true, + items: [ + 'gis/geo-reference', + 'gis/geo-object', + 'gis/geojson-layer', + 'gis/tile-layer', + ], + }, ], }; diff --git a/docs/src/components/gis/GeoJsonLayer.tsx b/docs/src/components/gis/GeoJsonLayer.tsx new file mode 100644 index 0000000..a6861a1 --- /dev/null +++ b/docs/src/components/gis/GeoJsonLayer.tsx @@ -0,0 +1,106 @@ +import * as THREE from 'three' +import { Scene, GeoJsonLayer } from 'react-three-lite' +import type { GeoJsonData, GeoJsonStyle, SceneComponents } from 'react-three-lite' + +interface AppProps { + rendererType?: 'webgpu' | 'webgl' +} + +const ORIGIN = { lng: 116.397, lat: 39.909 } + +// City-scale grid options; Scene builds the GridHelper and disposes it on unmount. +const GRID = { size: 1200, divisions: 24, colorCenterLine: 0x4a6fa5, colorGrid: 0x2f3e5c } + +const data: GeoJsonData = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { kind: 'park' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.395, 39.9095], + [116.399, 39.9095], + [116.399, 39.9115], + [116.395, 39.9115], + [116.395, 39.9095] + ] + ] + } + }, + { + type: 'Feature', + properties: { kind: 'block' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.3972, 39.9068], + [116.3992, 39.9068], + [116.3992, 39.9084], + [116.3972, 39.9084], + [116.3972, 39.9068] + ] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [ + [116.394, 39.9075], + [116.4005, 39.9075], + [116.4005, 39.9125] + ] + } + }, + { + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [116.397, 39.909] + } + } + ] +} + +const COLORS: Record = { + park: { fillColor: 0x4caf50, strokeColor: 0xb9f6ca, opacity: 0.7 }, + block: { fillColor: 0xff7043, strokeColor: 0xffab91, opacity: 0.7 } +} + +export default function App({ rendererType }: AppProps) { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint looks north over the dataset; the scene + // places the camera from local metres and it looks back at the origin, + // so the view stays north-up. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + } + + return ( + + { + const kind = feature.properties?.kind + return typeof kind === 'string' ? COLORS[kind] : undefined + }} + /> + + ) +} diff --git a/docs/src/components/gis/GeoObject.tsx b/docs/src/components/gis/GeoObject.tsx new file mode 100644 index 0000000..9e310b7 --- /dev/null +++ b/docs/src/components/gis/GeoObject.tsx @@ -0,0 +1,54 @@ +import * as THREE from 'three' +import { Scene, GeoObject } from 'react-three-lite' +import type { GeoPoint, SceneComponents } from 'react-three-lite' + +interface AppProps { + rendererType?: 'webgpu' | 'webgl' +} + +// Scene origin in WGS84; anchored markers below are GCJ02 coordinates as +// returned by Chinese map services such as AMap or Tencent. +const ORIGIN: GeoPoint = { lng: 116.391, lat: 39.907 } + +// City-scale grid options; Scene builds the GridHelper and disposes it on unmount. +const GRID = { size: 1400, divisions: 28, colorCenterLine: 0x4a6fa5, colorGrid: 0x2f3e5c } + +// GCJ02 ("Mars") coordinates near Tiananmen Square. +const MARKERS: GeoPoint[] = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } +] + +function decorate(object: THREE.Object3D): void { + const geometry = new THREE.ConeGeometry(40, 120, 24) + const material = new THREE.MeshBasicMaterial({ color: 0xffd166 }) + const cone = new THREE.Mesh(geometry, material) + cone.position.y = 60 + object.add(cone) +} + +export default function App({ rendererType }: AppProps) { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the markers; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 750, z: 1000 }) + camera.lookAt(0, 0, 0) + } + + return ( + + {MARKERS.map((coordinate, index) => ( + + ))} + + ) +} diff --git a/docs/src/components/gis/GeoReference.tsx b/docs/src/components/gis/GeoReference.tsx new file mode 100644 index 0000000..789f82d --- /dev/null +++ b/docs/src/components/gis/GeoReference.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { Scene } from 'react-three-lite' +import type { GeoPoint, SceneComponents } from 'react-three-lite' + +interface AppProps { + rendererType?: 'webgpu' | 'webgl' +} + +// Tiananmen Square, Beijing (WGS84). +const ORIGIN: GeoPoint = { lng: 116.397, lat: 39.909 } + +// City-scale grid options; Scene builds the GridHelper and disposes it on unmount. +const GRID = { size: 1200, divisions: 24, colorCenterLine: 0x4a6fa5, colorGrid: 0x2f3e5c } + +// Nearby landmarks expressed as raw geographic coordinates. +const PLACES: { point: GeoPoint; color: number }[] = [ + { point: ORIGIN, color: 0xffd166 }, + { point: { lng: 116.4005, lat: 39.909 }, color: 0x4fc3f7 }, + { point: { lng: 116.397, lat: 39.9117 }, color: 0x81c784 }, + { point: { lng: 116.3935, lat: 39.9063 }, color: 0xe57373 } +] + +export default function App({ rendererType }: AppProps) { + const objectsRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A raised south-side viewpoint looks north over the landmarks; the scene + // places the camera from local metres and it looks back at the origin. + scene.setPosition(camera, { x: 0, y: 500, z: 700 }) + camera.lookAt(0, 0, 0) + + PLACES.forEach(({ point, color }) => { + const radius = 35 + const geometry = new THREE.SphereGeometry(radius, 24, 16) + const material = new THREE.MeshBasicMaterial({ color }) + const marker = new THREE.Mesh(geometry, material) + // The scene carries its own GeoReference, so it projects the point itself. + // A [lng, lat, alt] tuple works here exactly like the {lng, lat} object. + scene.setPosition(marker, point) + // Rest the sphere on the ground plane instead of burying its lower half. + marker.position.y = radius + scene.add(marker) + objectsRef.current.push(marker) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + objectsRef.current.forEach((object) => { + object.removeFromParent() + const mesh = object as THREE.Mesh + mesh.geometry?.dispose() + const material = mesh.material as THREE.Material | undefined + material?.dispose() + }) + objectsRef.current = [] + } + }, []) + + return ( + + ) +} diff --git a/docs/src/components/gis/TileLayer.tsx b/docs/src/components/gis/TileLayer.tsx new file mode 100644 index 0000000..e739245 --- /dev/null +++ b/docs/src/components/gis/TileLayer.tsx @@ -0,0 +1,62 @@ +import * as THREE from 'three' +import { Scene, TileLayer, GeoObject } from 'react-three-lite' +import type { GeoPoint, SceneComponents } from 'react-three-lite' + +interface AppProps { + rendererType?: 'webgpu' | 'webgl' +} + +// WGS84 (GPS standard) point near Suzhou; used both as the local origin and the pin anchor. +const PIN: GeoPoint = { lng: 120.60682, lat: 31.330898 } +const ORIGIN = PIN + +// AMap (Gaode) raster tiles are indexed in GCJ02 coordinates. Its CDN answers +// the initial tile burst reliably from this network, unlike +// tile.openstreetmap.org, which rate-limits bulk GET requests to timeouts. +const TILE_URL = + 'https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}' +const TILE_SUBDOMAINS = ['1', '2', '3', '4'] +// AMap's max zoom for this style; zooming in past level 15 then streams +// genuinely finer tiles instead of reusing the cached level-15 imagery. +const TILE_ZOOM = 17 + +// GeoObject creates an internal Group and disposes its meshes on unmount. +const decoratePin = (anchor: THREE.Object3D) => { + const cone = new THREE.Mesh( + new THREE.ConeGeometry(40, 120, 24), + new THREE.MeshBasicMaterial({ color: 0xff3b30 }) + ) + // Cone is 120 tall and centred on its midpoint; lift it so the base sits on the ground. + cone.position.y = 60 + anchor.add(cone) +} + +export default function App({ rendererType }: AppProps) { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + // A fixed south-side viewpoint keeps the raster map readable like a + // conventional map. The scene projects the camera itself, so the position + // is just local metres; looking north from due south keeps it north-up. + // The 45-degree depression keeps every frustum corner ray on the ground, + // so the whole frame stays on the map: with the 75-degree fov, a steeper + // tilt pushes the bottom edge past the zenith (a sky band at the bottom) + // and a shallower one lifts the top edge past the horizon (background sky + // at the top). + scene.setPosition(camera, { x: 0, y: 450, z: 450 }) + camera.lookAt(0, 0, 0) + } + + return ( + + + + + ) +} diff --git a/src/__tests__/CameraFit.test.ts b/src/__tests__/CameraFit.test.ts new file mode 100644 index 0000000..5696318 --- /dev/null +++ b/src/__tests__/CameraFit.test.ts @@ -0,0 +1,247 @@ +import * as THREE from 'three' +import { describe, expect, it, vi } from 'vitest' +import { + fitCameraToBox, + fitCameraToObject, + fitCameraToGeoPoints, + fitCameraToPoints, + isGeoPoint +} from '../utils/CameraFit' +import { GeoReference } from '../crs/GeoReference' + +function makeCamera(aspect = 2): THREE.PerspectiveCamera { + return new THREE.PerspectiveCamera(75, aspect, 0.1, 1000) +} + +function boxAt(min: [number, number, number], max: [number, number, number]): THREE.Box3 { + return new THREE.Box3(new THREE.Vector3(...min), new THREE.Vector3(...max)) +} + +describe('fitCameraToBox', () => { + it('looks at the box centre', () => { + const camera = makeCamera() + const box = boxAt([10, 0, -5], [30, 10, 5]) + fitCameraToBox(camera, box) + camera.updateMatrixWorld() + + const viewDir = new THREE.Vector3() + camera.getWorldDirection(viewDir) + const toCenter = new THREE.Vector3(20, 5, 0).sub(camera.position).normalize() + expect(viewDir.dot(toCenter)).toBeCloseTo(1, 5) + }) + + it('places the camera at the requested elevation and azimuth', () => { + const camera = makeCamera() + fitCameraToBox(camera, boxAt([-50, 0, -50], [50, 0, 50]), { + elevation: Math.PI / 2, + azimuth: 0, + padding: 1 + }) + expect(camera.position.x).toBeCloseTo(0, 6) + expect(camera.position.z).toBeCloseTo(0, 6) + expect(camera.position.y).toBeGreaterThan(0) + }) + + it('keeps the whole box within the frustum at the default elevation', () => { + const camera = makeCamera() + const box = boxAt([-200, 0, -200], [200, 120, 200]) + fitCameraToBox(camera, box) + + const corners = [ + new THREE.Vector3(-200, 0, -200), + new THREE.Vector3(200, 0, -200), + new THREE.Vector3(-200, 120, 200), + new THREE.Vector3(200, 120, 200) + ] + for (const corner of corners) { + const projected = corner.clone().project(camera) + expect(projected.z).toBeLessThanOrEqual(1) + expect(Math.abs(projected.x)).toBeLessThanOrEqual(1) + expect(Math.abs(projected.y)).toBeLessThanOrEqual(1) + } + }) + + it('derives near/far from the content scale instead of fixed numbers', () => { + const city = makeCamera() + fitCameraToBox(city, boxAt([-400, 0, -400], [400, 120, 400])) + expect(city.far).toBeGreaterThan(800) + + const particle = makeCamera() + fitCameraToBox(particle, boxAt([-1, 0, -1], [1, 3, 1])) + expect(particle.far).toBeLessThan(city.far / 10) + }) + + it('respects an explicit distance override', () => { + const camera = makeCamera() + fitCameraToBox(camera, boxAt([-10, 0, -10], [10, 10, 10]), { distance: 250 }) + const center = new THREE.Vector3(0, 5, 0) + expect(camera.position.distanceTo(center)).toBeCloseTo(250, 5) + }) + + it('leaves an empty box untouched', () => { + const camera = makeCamera() + const before = camera.position.toArray() + fitCameraToBox(camera, new THREE.Box3()) + expect(camera.position.toArray()).toEqual(before) + }) + + it('can keep the clipping planes unchanged', () => { + const camera = makeCamera() + fitCameraToBox(camera, boxAt([-400, 0, -400], [400, 120, 400]), { adjustClip: false }) + expect(camera.near).toBe(0.1) + expect(camera.far).toBe(1000) + }) +}) + +describe('fitCameraToObject', () => { + it('frames the bounding box of a single object', () => { + const camera = makeCamera() + const mesh = new THREE.Mesh(new THREE.BoxGeometry(20, 20, 20)) + mesh.position.set(0, 10, 0) + fitCameraToObject(camera, mesh) + expect(camera.position.length()).toBeGreaterThan(10) + }) + + it('frames the union of several objects', () => { + const camera = makeCamera() + const geometry = new THREE.BoxGeometry(10, 10, 10) + const a = new THREE.Mesh(geometry) + a.position.set(-100, 5, 0) + const b = new THREE.Mesh(geometry) + b.position.set(100, 5, 0) + fitCameraToObject(camera, [a, b]) + expect(camera.position.distanceTo(new THREE.Vector3(0, 5, 0))).toBeGreaterThan(100) + }) +}) + +describe('fitCameraToGeoPoints', () => { + const ORIGIN = { lng: 116.391, lat: 39.907 } + const MARKERS = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } + ] + + it('matches framing the equivalent local metre box', () => { + const geo = new GeoReference(ORIGIN) + const viaGeo = makeCamera() + fitCameraToGeoPoints(viaGeo, MARKERS, geo) + + const box = new THREE.Box3() + for (const point of MARKERS) box.expandByPoint(geo.toLocal(point)) + const viaBox = makeCamera() + fitCameraToBox(viaBox, box) + + expect(viaGeo.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + viaBox.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('is a no-op for an empty point list', () => { + const geo = new GeoReference(ORIGIN) + const camera = makeCamera() + const before = camera.position.toArray() + fitCameraToGeoPoints(camera, [], geo) + expect(camera.position.toArray()).toEqual(before) + }) + + it('syncs controls.target to the content centre when provided', () => { + const geo = new GeoReference(ORIGIN) + const camera = makeCamera() + const update = vi.fn() + const controls = { + target: new THREE.Vector3(), + update + } as unknown as import('three-stdlib').OrbitControls + fitCameraToGeoPoints(camera, MARKERS, geo, undefined, { controls }) + expect(update).toHaveBeenCalled() + expect(controls.target.length()).toBeGreaterThan(0) + }) +}) + +describe('isGeoPoint', () => { + it('recognises geographic points by shape', () => { + expect(isGeoPoint({ lng: 116.4, lat: 39.9 })).toBe(true) + expect(isGeoPoint({ lng: 116.4, lat: 39.9, alt: 10 })).toBe(true) + }) + + it('treats local metre points as non-geographic', () => { + expect(isGeoPoint(new THREE.Vector3(1, 2, 3))).toBe(false) + expect(isGeoPoint({ x: 1, y: 2, z: 3 })).toBe(false) + expect(isGeoPoint({ x: 1, y: 2 })).toBe(false) + }) +}) + +describe('fitCameraToPoints', () => { + const ORIGIN = { lng: 116.391, lat: 39.907 } + const MARKERS = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } + ] + + it('frames geographic points via options.geo like fitCameraToGeoPoints', () => { + const geo = new GeoReference(ORIGIN) + const unified = makeCamera() + fitCameraToPoints(unified, MARKERS, { geo }) + + const legacy = makeCamera() + fitCameraToGeoPoints(legacy, MARKERS, geo) + + expect(unified.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + legacy.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('honours options.datum for geographic points', () => { + const geo = new GeoReference(ORIGIN, { datum: 'WGS84' }) + const camera = makeCamera() + expect(() => fitCameraToPoints(camera, MARKERS, { geo, datum: 'GCJ02' })).not.toThrow() + expect(camera.position.length()).toBeGreaterThan(0) + }) + + it('frames local Vector3 points without any geo reference', () => { + const camera = makeCamera() + fitCameraToPoints(camera, [new THREE.Vector3(-100, 0, 0), new THREE.Vector3(100, 0, 0)]) + + const box = boxAt([-100, 0, 0], [100, 0, 0]) + const expected = makeCamera() + fitCameraToBox(expected, box) + + expect(camera.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('frames plain {x, y, z} local points, defaulting z to zero', () => { + const camera = makeCamera() + fitCameraToPoints(camera, [ + { x: -50, y: 0 }, + { x: 50, y: 10 } + ]) + expect(camera.position.length()).toBeGreaterThan(0) + }) + + it('throws when geographic points are given without a geo reference', () => { + const camera = makeCamera() + expect(() => fitCameraToPoints(camera, MARKERS)).toThrow(/options\.geo is required/) + }) + + it('rejects mixing geographic and local points', () => { + const geo = new GeoReference(ORIGIN) + const camera = makeCamera() + expect(() => + fitCameraToPoints(camera, [MARKERS[0], new THREE.Vector3(0, 0, 0)], { geo }) + ).toThrow(/cannot mix/) + expect(() => + fitCameraToPoints(camera, [new THREE.Vector3(0, 0, 0), MARKERS[0]], { geo }) + ).toThrow(/cannot mix/) + }) + + it('is a no-op for an empty point list', () => { + const camera = makeCamera() + const before = camera.position.toArray() + fitCameraToPoints(camera, []) + expect(camera.position.toArray()).toEqual(before) + }) +}) diff --git a/src/__tests__/Datum.test.ts b/src/__tests__/Datum.test.ts new file mode 100644 index 0000000..d15baae --- /dev/null +++ b/src/__tests__/Datum.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + wgs84ToGcj02, + gcj02ToWgs84, + gcj02ToBd09, + bd09ToGcj02, + convertDatum +} from '../crs/Datum' + +const BEIJING = { lng: 116.404, lat: 39.915 } + +describe('Datum transforms', () => { + it('shifts mainland China WGS84 coordinates to GCJ02', () => { + const gcj = wgs84ToGcj02(BEIJING) + expect(gcj.lng).not.toBe(BEIJING.lng) + expect(gcj.lat).not.toBe(BEIJING.lat) + // The encryption offset in Beijing is a few hundred metres (<0.01 degree). + expect(Math.abs(gcj.lng - BEIJING.lng)).toBeLessThan(0.01) + expect(Math.abs(gcj.lat - BEIJING.lat)).toBeLessThan(0.01) + }) + + it('leaves coordinates outside mainland China untouched', () => { + const overseas = { lng: -74.006, lat: 40.7128 } + expect(wgs84ToGcj02(overseas)).toEqual(overseas) + expect(gcj02ToWgs84(overseas)).toEqual(overseas) + }) + + it('round-trips GCJ02 -> WGS84 -> GCJ02 to within a fraction of a metre', () => { + const gcj = wgs84ToGcj02(BEIJING) + const wgs = gcj02ToWgs84(gcj) + const again = wgs84ToGcj02(wgs) + // ~1e-7 degree is about 1 cm; fixed-point iteration is far tighter than that. + expect(again.lng).toBeCloseTo(gcj.lng, 6) + expect(again.lat).toBeCloseTo(gcj.lat, 6) + }) + + it('round-trips GCJ02 <-> BD09', () => { + const gcj = wgs84ToGcj02(BEIJING) + const bd = gcj02ToBd09(gcj) + expect(bd.lng).not.toBe(gcj.lng) + const back = bd09ToGcj02(bd) + expect(back.lng).toBeCloseTo(gcj.lng, 5) + expect(back.lat).toBeCloseTo(gcj.lat, 5) + }) + + it('preserves altitude through every transform', () => { + const withAlt = { ...BEIJING, alt: 42 } + expect(wgs84ToGcj02(withAlt).alt).toBe(42) + expect(convertDatum(withAlt, 'WGS84', 'BD09').alt).toBe(42) + }) + + it('returns the same point when source and target datum match', () => { + expect(convertDatum(BEIJING, 'GCJ02', 'GCJ02')).toBe(BEIJING) + }) + + it('converts WGS84 directly to BD09 and back', () => { + const bd = convertDatum(BEIJING, 'WGS84', 'BD09') + const wgs = convertDatum(bd, 'BD09', 'WGS84') + expect(wgs.lng).toBeCloseTo(BEIJING.lng, 5) + expect(wgs.lat).toBeCloseTo(BEIJING.lat, 5) + }) +}) diff --git a/src/__tests__/FitCamera.test.ts b/src/__tests__/FitCamera.test.ts new file mode 100644 index 0000000..14059b6 --- /dev/null +++ b/src/__tests__/FitCamera.test.ts @@ -0,0 +1,296 @@ +import * as THREE from 'three' +import { describe, expect, it, vi } from 'vitest' +import type { OrbitControls } from 'three-stdlib' +import { FitCamera } from '../utils/FitCamera' +import { fitCameraToBox, fitCameraToPoints } from '../utils/CameraFit' +import { GeoReference } from '../crs/GeoReference' +import { bindSceneGeo } from '../crs/SceneGeo' + +const ORIGIN = { lng: 116.391, lat: 39.907 } +const MARKERS = [ + { lng: 116.397428, lat: 39.90923 }, + { lng: 116.4005, lat: 39.9117 }, + { lng: 116.3945, lat: 39.912 } +] + +function plainCamera(aspect = 1): THREE.PerspectiveCamera { + return new THREE.PerspectiveCamera(75, aspect, 0.1, 1000) +} + +function boxAt(min: [number, number, number], max: [number, number, number]): THREE.Box3 { + return new THREE.Box3(new THREE.Vector3(...min), new THREE.Vector3(...max)) +} + +function mockControls(): { controls: OrbitControls; update: ReturnType } { + const update = vi.fn() + const controls = { + target: new THREE.Vector3(), + update + } as unknown as OrbitControls + return { controls, update } +} + +describe('FitCamera construction', () => { + it('is a drop-in THREE.PerspectiveCamera', () => { + const camera = new FitCamera() + expect(camera).toBeInstanceOf(FitCamera) + expect(camera).toBeInstanceOf(THREE.PerspectiveCamera) + expect(camera.isPerspectiveCamera).toBe(true) + }) + + it('uses the same constructor defaults as the scene camera factory', () => { + const camera = new FitCamera() + expect(camera.fov).toBe(75) + expect(camera.aspect).toBe(1) + expect(camera.near).toBe(0.1) + expect(camera.far).toBe(1000) + expect(camera.position.toArray()).toEqual([0, 0, 1]) + }) + + it('keeps a plain native THREE.Vector3 position', () => { + const camera = new FitCamera() + expect(camera.position).toBeInstanceOf(THREE.Vector3) + expect(Object.getPrototypeOf(camera.position)).toBe(THREE.Vector3.prototype) + }) + + it('accepts custom perspective parameters', () => { + const camera = new FitCamera(50, 1.6, 1, 500) + expect(camera.fov).toBe(50) + expect(camera.aspect).toBe(1.6) + expect(camera.near).toBe(1) + expect(camera.far).toBe(500) + }) +}) + +describe('FitCamera positioning', () => { + function geoScene(geo: GeoReference): THREE.Scene { + return bindSceneGeo(new THREE.Scene(), geo) + } + + it('keeps position.set as plain local metres', () => { + const camera = new FitCamera().bind(new GeoReference(ORIGIN)) + expect(camera.position.set(10, -5, 20)).toBe(camera.position) + expect(camera.position.toArray()).toEqual([10, -5, 20]) + }) + + it('places a camera through scene.setPosition using the scene reference', () => { + const geo = new GeoReference(ORIGIN) + const scene = geoScene(geo) + const camera = new FitCamera() + + expect(scene.setPosition(camera, { lng: 116.397428, lat: 39.90923, alt: 120 })).toBe(camera) + + const expected = geo.toLocal({ lng: 116.397428, lat: 39.90923, alt: 120 }) + expect(camera.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('places a camera exactly like a mesh through the same scene call', () => { + const scene = geoScene(new GeoReference(ORIGIN)) + const camera = new FitCamera() + const mesh = new THREE.Object3D() + + scene.setPosition(camera, { lng: 116.397428, lat: 39.90923, alt: 0 }) + scene.setPosition(mesh, { lng: 116.397428, lat: 39.90923, alt: 0 }) + + expect(camera.position.toArray()).toEqual(mesh.position.toArray()) + }) + + it('maps the local origin to (0, 0, 0) via scene.setPosition', () => { + const scene = geoScene(new GeoReference(ORIGIN)) + const camera = new FitCamera() + scene.setPosition(camera, { lng: ORIGIN.lng, lat: ORIGIN.lat }) + expect(camera.position.toArray().map((n) => Math.round(n * 1e6))).toEqual([0, 0, 0]) + }) + + it('projects longitude/latitude through an explicit datum', () => { + const geo = new GeoReference(ORIGIN, { datum: 'GCJ02' }) + const scene = geoScene(geo) + const camera = new FitCamera() + scene.setPosition(camera, { lng: 116.397428, lat: 39.90923 }, 'GCJ02') + + const expected = geo.toLocal({ lng: 116.397428, lat: 39.90923 }, 'GCJ02') + expect(camera.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.toArray().map((n) => Math.round(n * 1e6)) + ) + }) +}) + +describe('FitCamera.bind', () => { + it('stores the geo reference and controls for later calls', () => { + const geo = new GeoReference(ORIGIN) + const { controls } = mockControls() + const camera = new FitCamera().bind(geo, controls) + expect(camera.geo).toBe(geo) + expect(camera.controls).toBe(controls) + }) + + it('returns the camera instance for inline chaining', () => { + const camera = new FitCamera() + expect(camera.bind(new GeoReference(ORIGIN))).toBe(camera) + }) + + it('starts with a null controls binding', () => { + expect(new FitCamera().controls).toBeNull() + }) + + it('only updates the arguments that are actually passed', () => { + const geo = new GeoReference(ORIGIN) + const { controls } = mockControls() + const camera = new FitCamera().bind(geo, controls) + + camera.bind(undefined) + expect(camera.geo).toBe(geo) + expect(camera.controls).toBe(controls) + + camera.bind(undefined, null) + expect(camera.controls).toBeNull() + expect(camera.geo).toBe(geo) + + camera.bind(null) + expect(camera.geo).toBeUndefined() + expect(camera.controls).toBeNull() + }) +}) + +describe('FitCamera.fitToPoints', () => { + it('frames bound geographic points identically to the pure function', () => { + const geo = new GeoReference(ORIGIN) + const fit = new FitCamera().bind(geo) + fit.fitToPoints(MARKERS) + + const expected = plainCamera() + fitCameraToPoints(expected, MARKERS, { geo }) + + expect(fit.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('frames local metre points without any bound geo reference', () => { + const fit = new FitCamera() + fit.fitToPoints([new THREE.Vector3(-100, 0, 0), new THREE.Vector3(100, 10, 0)]) + + const expected = plainCamera() + fitCameraToBox(expected, boxAt([-100, 0, 0], [100, 10, 0])) + + expect(fit.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('lets a per-call geo win over the bound reference', () => { + const bound = new GeoReference({ lng: 0, lat: 0 }) + const perCall = new GeoReference(ORIGIN) + const fit = new FitCamera().bind(bound) + expect(() => fit.fitToPoints(MARKERS, { geo: perCall })).not.toThrow() + + const expected = plainCamera() + fitCameraToPoints(expected, MARKERS, { geo: perCall }) + expect(fit.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('throws for geographic points when no geo is bound or passed', () => { + const camera = new FitCamera() + expect(() => camera.fitToPoints(MARKERS)).toThrow(/options\.geo is required/) + }) + + it('rejects mixing geographic and local points', () => { + const geo = new GeoReference(ORIGIN) + const camera = new FitCamera().bind(geo) + expect(() => camera.fitToPoints([MARKERS[0], new THREE.Vector3(0, 0, 0)])).toThrow(/cannot mix/) + }) + + it('is a no-op for an empty point list', () => { + const camera = new FitCamera().bind(new GeoReference(ORIGIN)) + const before = camera.position.toArray() + expect(camera.fitToPoints([])).toBe(camera) + expect(camera.position.toArray()).toEqual(before) + }) + + it('returns the camera instance for chaining', () => { + const camera = new FitCamera() + expect( + camera.fitToPoints([ + { x: 0, y: 0 }, + { x: 10, y: 10 } + ]) + ).toBe(camera) + }) +}) + +describe('FitCamera framing with bound controls', () => { + it('syncs the bound controls target to the content centre', () => { + const { controls, update } = mockControls() + const camera = new FitCamera().bind(new GeoReference(ORIGIN), controls) + camera.fitToPoints(MARKERS) + expect(update).toHaveBeenCalled() + expect(controls.target.length()).toBeGreaterThan(0) + }) + + it('lets per-call controls override the bound ones', () => { + const bound = mockControls() + const perCall = mockControls() + const camera = new FitCamera().bind(new GeoReference(ORIGIN), bound.controls) + camera.fitToPoints(MARKERS, { controls: perCall.controls }) + expect(bound.update).not.toHaveBeenCalled() + expect(perCall.update).toHaveBeenCalled() + }) +}) + +describe('FitCamera.fitToObject', () => { + it('frames the bounding box of a single object', () => { + const camera = new FitCamera() + const mesh = new THREE.Mesh(new THREE.BoxGeometry(20, 20, 20)) + mesh.position.set(0, 10, 0) + expect(camera.fitToObject(mesh)).toBe(camera) + expect(camera.position.length()).toBeGreaterThan(10) + }) + + it('frames the union of several objects and equals the pure function', () => { + const geometry = new THREE.BoxGeometry(10, 10, 10) + const a = new THREE.Mesh(geometry) + a.position.set(-100, 5, 0) + const b = new THREE.Mesh(geometry) + b.position.set(100, 5, 0) + + const fit = new FitCamera() + fit.fitToObject([a, b]) + + const expected = plainCamera() + const box = new THREE.Box3() + box.expandByObject(a).expandByObject(b) + fitCameraToBox(expected, box) + + expect(fit.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) +}) + +describe('FitCamera.fitToBox', () => { + it('frames an explicit local box identically to the pure function', () => { + const box = boxAt([-200, 0, -200], [200, 120, 200]) + const fit = new FitCamera() + expect(fit.fitToBox(box)).toBe(fit) + + const expected = plainCamera() + fitCameraToBox(expected, box) + + expect(fit.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.position.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('syncs bound controls while fitting a box', () => { + const { controls, update } = mockControls() + const camera = new FitCamera() + camera.controls = controls + camera.fitToBox(boxAt([-10, 0, -10], [10, 10, 10])) + expect(update).toHaveBeenCalled() + expect(controls.target).toEqual(new THREE.Vector3(0, 5, 0)) + }) +}) diff --git a/src/__tests__/GeoJson.test.ts b/src/__tests__/GeoJson.test.ts new file mode 100644 index 0000000..385c9a1 --- /dev/null +++ b/src/__tests__/GeoJson.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { collectGeometries, flattenGeoJson } from '../crs/GeoJson' +import type { GeoJsonData } from '../crs/GeoJson' + +const collection: GeoJsonData = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [116.4, 39.9, 10] }, + properties: { name: 'marker' } + }, + { + type: 'Feature', + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [116.4, 39.9], + [116.41, 39.91] + ], + [ + [116.42, 39.92], + [116.43, 39.93] + ] + ] + }, + properties: null + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [116.4, 39.9], + [116.41, 39.9], + [116.41, 39.91], + [116.4, 39.9] + ], + [ + [116.404, 39.904], + [116.406, 39.904], + [116.406, 39.906], + [116.404, 39.904] + ] + ] + }, + properties: { height: 50 } + }, + { type: 'Feature', geometry: null, properties: null } + ] +} + +describe('flattenGeoJson', () => { + it('buckets points, lines and polygon rings', () => { + const flat = flattenGeoJson(collection) + expect(flat.points).toHaveLength(1) + expect(flat.points[0]).toEqual({ lng: 116.4, lat: 39.9, alt: 10 }) + expect(flat.lines).toHaveLength(2) + expect(flat.lines[0]).toHaveLength(2) + expect(flat.polygons).toHaveLength(1) + expect(flat.polygons[0].outer).toHaveLength(4) + expect(flat.polygons[0].holes).toHaveLength(1) + }) + + it('defaults missing altitude to zero', () => { + const flat = flattenGeoJson({ type: 'Point', coordinates: [1, 2] }) + expect(flat.points[0].alt).toBe(0) + }) + + it('expands multi-point and multi-polygon geometries', () => { + const flat = flattenGeoJson({ + type: 'MultiPolygon', + coordinates: [ + [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0] + ] + ], + [ + [ + [2, 2], + [3, 2], + [3, 3], + [2, 2] + ] + ] + ] + }) + expect(flat.polygons).toHaveLength(2) + }) + + it('skips null feature geometries while collecting', () => { + const geometries = collectGeometries(collection) + expect(geometries).toHaveLength(3) + }) + + it('accepts a single bare geometry', () => { + const flat = flattenGeoJson({ + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1] + ] + }) + expect(flat.lines[0][1]).toEqual({ lng: 1, lat: 1, alt: 0 }) + }) +}) diff --git a/src/__tests__/GeoReference.test.ts b/src/__tests__/GeoReference.test.ts new file mode 100644 index 0000000..405ca25 --- /dev/null +++ b/src/__tests__/GeoReference.test.ts @@ -0,0 +1,124 @@ +import * as THREE from 'three' +import { describe, expect, it } from 'vitest' +import { GeoReference } from '../crs/GeoReference' +import { wgs84ToGcj02 } from '../crs/Datum' + +const BEIJING = { lng: 116.397, lat: 39.908 } +const METERS_PER_DEG_LAT = 111320 + +describe('GeoReference (EPSG:4326 equirectangular plane)', () => { + const geo = new GeoReference(BEIJING) + + it('maps the origin to the scene origin', () => { + const p = geo.toLocal(BEIJING) + expect(p.x).toBeCloseTo(0, 6) + expect(p.y).toBeCloseTo(0, 6) + expect(p.z).toBeCloseTo(0, 6) + }) + + it('maps a northward offset onto -Z with the default axis mapping', () => { + const north = geo.toLocal({ lng: BEIJING.lng, lat: BEIJING.lat + 0.001 }) + expect(north.x).toBeCloseTo(0, 6) + expect(north.z).toBeCloseTo(-METERS_PER_DEG_LAT * 0.001, 1) + expect(north.y).toBeCloseTo(0, 6) + }) + + it('maps an eastward offset onto +X scaled by cos(latitude)', () => { + const east = geo.toLocal({ lng: BEIJING.lng + 0.001, lat: BEIJING.lat }) + const expected = METERS_PER_DEG_LAT * Math.cos((BEIJING.lat * Math.PI) / 180) * 0.001 + expect(east.x).toBeCloseTo(expected, 1) + expect(east.z).toBeCloseTo(0, 6) + }) + + it('maps height onto +Y', () => { + const up = geo.toLocal({ ...BEIJING, alt: 100 }) + expect(up.y).toBeCloseTo(100, 6) + }) + + it('round-trips toLocal/toGeo', () => { + const target = { lng: BEIJING.lng + 0.01, lat: BEIJING.lat - 0.02, alt: 30 } + const local = geo.toLocal(target) + const back = geo.toGeo(local) + expect(back.lng).toBeCloseTo(target.lng, 9) + expect(back.lat).toBeCloseTo(target.lat, 9) + expect(back.alt).toBeCloseTo(30, 6) + }) + + it('keeps scene coordinates small even though absolute Mercator values are huge', () => { + const local = geo.toLocal({ lng: BEIJING.lng + 0.0001, lat: BEIJING.lat + 0.0001 }) + expect(Math.abs(local.x)).toBeLessThan(20) + expect(Math.abs(local.z)).toBeLessThan(20) + }) +}) + +describe('GeoReference datum handling', () => { + it('converts GCJ02 input to the same local point as its WGS84 equivalent', () => { + const geo = new GeoReference(BEIJING, { datum: 'WGS84' }) + const gcjPoint = wgs84ToGcj02({ lng: BEIJING.lng + 0.01, lat: BEIJING.lat + 0.01 }) + const wgsPoint = { lng: BEIJING.lng + 0.01, lat: BEIJING.lat + 0.01 } + + const fromGcj = geo.toLocal(gcjPoint, 'GCJ02') + const fromWgs = geo.toLocal(wgsPoint, 'WGS84') + expect(fromGcj.x).toBeCloseTo(fromWgs.x, 4) + expect(fromGcj.z).toBeCloseTo(fromWgs.z, 4) + }) + + it('returns coordinates in the configured datum from toGeo', () => { + const geo = new GeoReference(BEIJING, { datum: 'GCJ02' }) + const wgsPoint = { lng: BEIJING.lng + 0.01, lat: BEIJING.lat + 0.01 } + const local = geo.toLocal(wgsPoint, 'WGS84') + const back = geo.toGeo(local) + const expected = wgs84ToGcj02(wgsPoint) + expect(back.lng).toBeCloseTo(expected.lng, 6) + expect(back.lat).toBeCloseTo(expected.lat, 6) + }) +}) + +describe('GeoReference (EPSG:3857 Mercator plane)', () => { + it('recenters Mercator metres around the origin and round-trips', () => { + const geo = new GeoReference(BEIJING, { crs: 'EPSG:3857' }) + const local = geo.toLocal({ lng: BEIJING.lng + 0.005, lat: BEIJING.lat + 0.005 }) + expect(Math.abs(local.x)).toBeLessThan(1000) + expect(Math.abs(local.z)).toBeLessThan(1000) + + const back = geo.toGeo(local) + expect(back.lng).toBeCloseTo(BEIJING.lng + 0.005, 8) + expect(back.lat).toBeCloseTo(BEIJING.lat + 0.005, 8) + }) +}) + +describe('GeoReference custom axis mapping', () => { + it('supports a Z-up ENU convention and validates uniqueness', () => { + const geo = new GeoReference(BEIJING, { axes: { east: 'x', north: 'y', up: 'z' } }) + const north = geo.toLocal({ lng: BEIJING.lng, lat: BEIJING.lat + 0.001 }) + expect(north.y).toBeGreaterThan(0) + const up = geo.toLocal({ ...BEIJING, alt: 10 }) + expect(up.z).toBeCloseTo(10, 6) + + expect( + () => new GeoReference(BEIJING, { axes: { east: 'x', north: 'x', up: 'z' } }) + ).toThrow() + }) +}) + +describe('GeoReference helpers', () => { + it('places an Object3D at a geographic position', () => { + const geo = new GeoReference(BEIJING) + const obj = new THREE.Object3D() + geo.setPosition(obj, { lng: BEIJING.lng, lat: BEIJING.lat + 0.001 }) + expect(obj.position.z).toBeCloseTo(-METERS_PER_DEG_LAT * 0.001, 1) + }) + + it('reports the planar distance between two points', () => { + const geo = new GeoReference(BEIJING) + const d = geo.distance(BEIJING, { lng: BEIJING.lng, lat: BEIJING.lat + 0.001 }) + expect(d).toBeCloseTo(METERS_PER_DEG_LAT * 0.001, 1) + }) + + it('exposes an orthonormal ENU basis matrix', () => { + const geo = new GeoReference(BEIJING) + const basis = geo.matrix + const det = basis.determinant() + expect(Math.abs(det)).toBeCloseTo(1, 6) + }) +}) diff --git a/src/__tests__/GroundSampling.test.ts b/src/__tests__/GroundSampling.test.ts new file mode 100644 index 0000000..1e31b44 --- /dev/null +++ b/src/__tests__/GroundSampling.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import * as THREE from 'three' +import { MAX_GROUND_RANGE_FACTOR, sampleVisibleGround } from '../utils/GroundSampling' + +const GROUND_Y = 0 + +/** Camera at (0, height, distance) looking at the world origin, so its view + * axis has a depression angle of atan(height / distance). */ +function makeCamera(height: number, distance: number, far = 4000): THREE.PerspectiveCamera { + const camera = new THREE.PerspectiveCamera(75, 2, 0.1, far) + camera.position.set(0, height, distance) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld() + return camera +} + +const horizontalDistance = (camera: THREE.PerspectiveCamera, point: THREE.Vector3): number => + Math.hypot(point.x - camera.position.x, point.z - camera.position.z) + +describe('sampleVisibleGround', () => { + it('covers both the ground behind and ahead of a steep 57-degree view', () => { + // Depression ~57 degrees with a 75-degree fov: the bottom frustum edge + // passes 4.7 degrees past the zenith, so its corner rays still meet the + // ground but behind the camera (~857 m back) - the bottom band of the + // frame shows ground behind the viewer. The coverage rectangle must span + // that near band, the look-at point, and the distant upper corners. + const camera = makeCamera(700, 450) + const samples = sampleVisibleGround(camera, GROUND_Y) + const distances = samples.map((point) => horizontalDistance(camera, point)) + + expect(samples).toHaveLength(5) + // Near field: the view-centre probe lands on the look-at point. + expect(Math.min(...distances)).toBeLessThan(500) + // Far field: the upper corners reach the distant ground, within the cap. + expect(Math.max(...distances)).toBeGreaterThan(3000) + expect(Math.max(...distances)).toBeLessThanOrEqual(700 * MAX_GROUND_RANGE_FACTOR) + // The bounding rectangle spans ground behind the camera (z ~ +508) and + // far ahead of it (z ~ -1498). + const minZ = Math.min(...samples.map((point) => point.z)) + const maxZ = Math.max(...samples.map((point) => point.z)) + expect(minZ).toBeLessThan(-1400) + expect(maxZ).toBeGreaterThan(450) + samples.forEach((point) => expect(point.y).toBeCloseTo(GROUND_Y, 5)) + }) + + it('covers the full ground band of a 45-degree view, clamping far corners to the range cap', () => { + // Depression 45 degrees: every corner ray meets the ground ahead of the + // camera. The upper corners land beyond the cap of 6 * 450 = 2700 m and + // are clamped along their bearing; the lower corners land ~556 m ahead. + const camera = makeCamera(450, 450) + const samples = sampleVisibleGround(camera, GROUND_Y) + const distances = samples.map((point) => horizontalDistance(camera, point)) + + expect(Math.min(...distances)).toBeLessThan(600) + expect(Math.max(...distances)).toBeGreaterThan(2600) + expect(Math.max(...distances)).toBeLessThanOrEqual(2700 + 1) + // The bounding rectangle spans the whole view band, near edge to clamped + // far edge, entirely on the northern side of the camera. + const minZ = Math.min(...samples.map((point) => point.z)) + const maxZ = Math.max(...samples.map((point) => point.z)) + expect(minZ).toBeLessThan(-1200) + expect(maxZ).toBeGreaterThan(380) + expect(maxZ).toBeLessThan(450) + }) + + it('keeps the look-at point covered when a shallow tilt lifts corners above the horizon', () => { + // Depression 15 degrees (the maxPolarAngle limit tilted back): the top + // frustum edge points above the horizon. Skyward corners collapse to the + // camera's ground position while the centre ray still reaches the look-at + // point ~2614 m ahead, so the visible middle ground stays covered. + const camera = makeCamera(700, 2614) + const samples = sampleVisibleGround(camera, GROUND_Y) + const distances = samples.map((point) => horizontalDistance(camera, point)) + + expect(Math.min(...distances)).toBeLessThan(50) + expect(Math.max(...distances)).toBeGreaterThan(2600) + expect(Math.max(...distances)).toBeLessThanOrEqual(700 * MAX_GROUND_RANGE_FACTOR) + // The look-at point itself is inside the sampled bounding box. + const minX = Math.min(...samples.map((point) => point.x)) + const maxX = Math.max(...samples.map((point) => point.x)) + const minZ = Math.min(...samples.map((point) => point.z)) + const maxZ = Math.max(...samples.map((point) => point.z)) + expect(minX).toBeLessThanOrEqual(0) + expect(maxX).toBeGreaterThanOrEqual(0) + expect(minZ).toBeLessThanOrEqual(0) + expect(maxZ).toBeGreaterThanOrEqual(0) + }) + + it('surrounds the ground below a top-down camera', () => { + // Straight down: the default up vector is parallel to the view axis, so + // point the camera up along -z before lookAt to keep the basis valid. + const camera = new THREE.PerspectiveCamera(75, 2, 0.1, 4000) + camera.position.set(0, 100, 0) + camera.up.set(0, 0, -1) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld() + const samples = sampleVisibleGround(camera, GROUND_Y) + + expect(samples).toHaveLength(5) + const minX = Math.min(...samples.map((point) => point.x)) + const maxX = Math.max(...samples.map((point) => point.x)) + const minZ = Math.min(...samples.map((point) => point.z)) + const maxZ = Math.max(...samples.map((point) => point.z)) + expect(minX).toBeLessThan(0) + expect(maxX).toBeGreaterThan(0) + expect(minZ).toBeLessThan(0) + expect(maxZ).toBeGreaterThan(0) + samples.forEach((point) => { + expect(point.y).toBeCloseTo(GROUND_Y, 5) + expect(horizontalDistance(camera, point)).toBeLessThan(600) + }) + }) +}) diff --git a/src/__tests__/Mercator.test.ts b/src/__tests__/Mercator.test.ts new file mode 100644 index 0000000..9dc7a1d --- /dev/null +++ b/src/__tests__/Mercator.test.ts @@ -0,0 +1,42 @@ +import * as THREE from 'three' +import { describe, expect, it } from 'vitest' +import { mercatorProject, mercatorUnproject } from '../crs/Mercator' +import { WGS84_A, MERCATOR_MAX_LAT } from '../crs/constants' + +const HALF_WORLD = Math.PI * WGS84_A + +describe('Web Mercator (EPSG:3857)', () => { + it('projects the equator/prime-meridian intersection to the origin', () => { + const m = mercatorProject({ lng: 0, lat: 0 }) + expect(m.x).toBeCloseTo(0, 6) + expect(m.y).toBeCloseTo(0, 6) + }) + + it('projects longitude linearly in metres', () => { + const east = mercatorProject({ lng: 180, lat: 0 }) + expect(east.x).toBeCloseTo(HALF_WORLD, 4) + const west = mercatorProject({ lng: -180, lat: 0 }) + expect(west.x).toBeCloseTo(-HALF_WORLD, 4) + }) + + it('clamps latitudes beyond the Mercator limit', () => { + const clamped = mercatorProject({ lng: 0, lat: 89 }) + const limit = mercatorProject({ lng: 0, lat: MERCATOR_MAX_LAT }) + expect(clamped.y).toBeCloseTo(limit.y, 6) + }) + + it('round-trips project/unproject', () => { + const original = { lng: 116.404, lat: 39.915, alt: 50 } + const m = mercatorProject(original) + const back = mercatorUnproject(m) + expect(back.lng).toBeCloseTo(original.lng, 8) + expect(back.lat).toBeCloseTo(original.lat, 8) + expect(back.alt).toBe(50) + }) + + it('can write into an existing vector', () => { + const target = new THREE.Vector3() + const result = mercatorProject({ lng: 10, lat: 10 }, target) + expect(result).toBe(target) + }) +}) diff --git a/src/__tests__/SceneGeo.test.ts b/src/__tests__/SceneGeo.test.ts new file mode 100644 index 0000000..9e084ed --- /dev/null +++ b/src/__tests__/SceneGeo.test.ts @@ -0,0 +1,158 @@ +import * as THREE from 'three' +import { describe, expect, it } from 'vitest' +import { GeoReference } from '../crs/GeoReference' +import { bindSceneGeo, getSceneGeo } from '../crs/SceneGeo' +import { FitCamera } from '../utils/FitCamera' + +const ORIGIN = { lng: 116.391, lat: 39.907 } +const POINT = { lng: 116.397428, lat: 39.90923, alt: 80 } + +describe('bindSceneGeo', () => { + it('returns the same scene for chaining', () => { + const scene = new THREE.Scene() + expect(bindSceneGeo(scene, new GeoReference(ORIGIN))).toBe(scene) + }) + + it('exposes the bound reference through scene.getGeo and getSceneGeo', () => { + const geo = new GeoReference(ORIGIN) + const scene = bindSceneGeo(new THREE.Scene(), geo) + expect(scene.getGeo?.()).toBe(geo) + expect(getSceneGeo(scene)).toBe(geo) + }) + + it('returns undefined for a scene without a bound reference', () => { + const scene = new THREE.Scene() + expect(getSceneGeo(scene)).toBeUndefined() + expect(scene.getGeo).toBeUndefined() + expect(scene.setPosition).toBeUndefined() + }) + + it('installs helpers without a reference for local-coordinate fallback', () => { + const scene = bindSceneGeo(new THREE.Scene()) + expect(getSceneGeo(scene)).toBeUndefined() + expect(typeof scene.setPosition).toBe('function') + expect(scene.getGeo?.()).toBeUndefined() + }) + + it('rebinds to a new reference when called again', () => { + const first = new GeoReference(ORIGIN) + const second = new GeoReference({ lng: 121.47, lat: 31.23 }) + const scene = bindSceneGeo(new THREE.Scene(), first) + bindSceneGeo(scene, second) + expect(getSceneGeo(scene)).toBe(second) + }) +}) + +describe('scene.setPosition with a bound reference', () => { + it('projects a {lng, lat, alt} object exactly like geo.toLocal', () => { + const geo = new GeoReference(ORIGIN) + const scene = bindSceneGeo(new THREE.Scene(), geo) + const object = new THREE.Object3D() + + scene.setPosition(object, POINT) + + const expected = geo.toLocal(POINT) + expect(object.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('projects a [lng, lat, alt] tuple exactly like geo.toLocal', () => { + const geo = new GeoReference(ORIGIN) + const scene = bindSceneGeo(new THREE.Scene(), geo) + const object = new THREE.Object3D() + + scene.setPosition(object, [POINT.lng, POINT.lat, POINT.alt]) + + const expected = geo.toLocal(POINT) + expect(object.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('defaults a two-element [lng, lat] tuple altitude to 0', () => { + const geo = new GeoReference(ORIGIN) + const scene = bindSceneGeo(new THREE.Scene(), geo) + const object = new THREE.Object3D() + + scene.setPosition(object, [POINT.lng, POINT.lat]) + + const expected = geo.toLocal({ lng: POINT.lng, lat: POINT.lat }) + expect(object.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('positions a FitCamera and a mesh identically for the same tuple', () => { + const scene = bindSceneGeo(new THREE.Scene(), new GeoReference(ORIGIN)) + const camera = new FitCamera() + const mesh = new THREE.Object3D() + + scene.setPosition(camera, [POINT.lng, POINT.lat, 0]) + scene.setPosition(mesh, [POINT.lng, POINT.lat, 0]) + + expect(camera.position.toArray()).toEqual(mesh.position.toArray()) + }) + + it('maps the scene origin tuple to (0, 0, 0)', () => { + const scene = bindSceneGeo(new THREE.Scene(), new GeoReference(ORIGIN)) + const object = new THREE.Object3D() + scene.setPosition(object, [ORIGIN.lng, ORIGIN.lat]) + expect(object.position.toArray().map((n) => Math.round(n * 1e6))).toEqual([0, 0, 0]) + }) + + it('honours an explicit datum for a tuple', () => { + const geo = new GeoReference(ORIGIN, { datum: 'GCJ02' }) + const scene = bindSceneGeo(new THREE.Scene(), geo) + const object = new THREE.Object3D() + scene.setPosition(object, [POINT.lng, POINT.lat], 'GCJ02') + + const expected = geo.toLocal({ lng: POINT.lng, lat: POINT.lat }, 'GCJ02') + expect(object.position.toArray().map((n) => Math.round(n * 1e6))).toEqual( + expected.toArray().map((n) => Math.round(n * 1e6)) + ) + }) + + it('uses a THREE.Vector3 as local metres without projecting', () => { + const scene = bindSceneGeo(new THREE.Scene(), new GeoReference(ORIGIN)) + const object = new THREE.Object3D() + scene.setPosition(object, new THREE.Vector3(10, -5, 20)) + expect(object.position.toArray()).toEqual([10, -5, 20]) + }) + + it('uses a {x, y, z} object as local metres without projecting', () => { + const scene = bindSceneGeo(new THREE.Scene(), new GeoReference(ORIGIN)) + const object = new THREE.Object3D() + scene.setPosition(object, { x: 10, y: -5 }) + expect(object.position.toArray()).toEqual([10, -5, 0]) + }) + + it('returns the object so calls can chain', () => { + const scene = bindSceneGeo(new THREE.Scene(), new GeoReference(ORIGIN)) + const object = new THREE.Object3D() + expect(scene.setPosition(object, [POINT.lng, POINT.lat])).toBe(object) + }) +}) + +describe('scene.setPosition fallback without a bound reference', () => { + it('maps a tuple straight to local x/y/z metres', () => { + const scene = bindSceneGeo(new THREE.Scene()) + const object = new THREE.Object3D() + scene.setPosition(object, [10, -5, 20]) + expect(object.position.toArray()).toEqual([10, -5, 20]) + }) + + it('maps a {lng, lat} object straight to local x/y and defaults z to 0', () => { + const scene = bindSceneGeo(new THREE.Scene()) + const camera = new FitCamera() + scene.setPosition(camera, { lng: 3, lat: 4 }) + expect(camera.position.toArray()).toEqual([3, 4, 0]) + }) + + it('still treats THREE.Vector3 as local metres', () => { + const scene = bindSceneGeo(new THREE.Scene()) + const object = new THREE.Object3D() + scene.setPosition(object, new THREE.Vector3(1, 2, 3)) + expect(object.position.toArray()).toEqual([1, 2, 3]) + }) +}) diff --git a/src/__tests__/TileLayer.integration.test.ts b/src/__tests__/TileLayer.integration.test.ts new file mode 100644 index 0000000..0fad257 --- /dev/null +++ b/src/__tests__/TileLayer.integration.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import * as React from 'react' +import { act } from 'react-dom/test-utils' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import * as THREE from 'three' +import { SceneContext, type SceneSlotProps } from '../context/SceneContext' +import type { SceneComponents } from '../context/SceneContext' +import TileLayer from '../components/TileLayer' +import { createGeoReference } from '../crs/GeoReference' + +const TEMPLATE = 'https://example.test/{z}/{x}/{y}.png' +const ORIGIN = { lng: 120.60682, lat: 31.330898 } + +type CapturedRequest = { + url: string + onLoad?: (texture: THREE.Texture) => void + onError?: (event: unknown) => void +} + +describe('TileLayer request pipeline (integration)', () => { + let loadSpy: MockInstance + let requests: CapturedRequest[] + let afterFrames: Array<(r: unknown, s: THREE.Scene, c: SceneComponents) => void> + let container: HTMLDivElement | null + let root: Root | null + let camera: THREE.PerspectiveCamera + let scene: THREE.Scene + + beforeEach(() => { + requests = [] + afterFrames = [] + loadSpy = vi.spyOn(THREE.TextureLoader.prototype, 'load').mockImplementation( + (url, onLoad, _onProgress, onError) => { + requests.push({ + url, + onLoad: onLoad as CapturedRequest['onLoad'], + onError: onError as CapturedRequest['onError'] + }) + return new THREE.Texture() + } + ) + }) + + afterEach(() => { + loadSpy.mockRestore() + if (root) { + act(() => { + root!.unmount() + }) + } + container?.remove() + container = null + root = null + }) + + const mount = async (): Promise => { + scene = new THREE.Scene() + scene.background = new THREE.Color('#1a1a2e') + camera = new THREE.PerspectiveCamera(75, 2, 0.1, 4000) + camera.position.set(0, 450, 450) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld() + const controls = { + target: new THREE.Vector3(0, 0, 0), + maxPolarAngle: Math.PI, + maxDistance: Infinity, + update: () => {}, + getPolarAngle: () => 0, + getAzimuthalAngle: () => 0 + } as unknown as SceneComponents['controls'] + const geo = createGeoReference(ORIGIN) + const sceneComponents: SceneComponents = { + camera, + light: new THREE.Object3D(), + axesHelper: undefined, + gridHelper: undefined, + controls, + geo + } + const ctx: SceneSlotProps = { + scene, + sceneComponents, + geo, + renderer: { domElement: { clientHeight: 300 } } as unknown as SceneSlotProps['renderer'], + addAfterFrame: (cb) => { + afterFrames.push(cb as typeof afterFrames[number]) + return () => {} + } + } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root!.render( + React.createElement( + SceneContext.Provider, + { value: ctx }, + React.createElement(TileLayer, { url: TEMPLATE, zoom: 15, datum: 'GCJ02' }) + ) + ) + }) + } + + const runFrame = (): void => { + act(() => { + afterFrames.forEach((cb) => cb({}, scene, {} as SceneComponents)) + }) + } + + const settleAllLoads = (): void => { + act(() => { + requests.forEach((request) => { + request.onLoad?.(new THREE.Texture()) + }) + }) + } + + it('issues initial tile requests through the load queue', async () => { + await mount() + + // The pyramid must enqueue the ideal set and pump at least one download + // (coarse root covers first). Zero requests here reproduces the reported + // "no tile image requested" symptom. + expect(requests.length).toBeGreaterThan(0) + expect(requests[0].url).toMatch(/\/0\/|\/1\//) + }) + + it('renders visible tile meshes once textures arrive', async () => { + await mount() + settleAllLoads() + + const group = scene.children.find((child) => child.name === 'TileLayer') + expect(group).toBeDefined() + const visibleTiles = (group as THREE.Group).children.filter( + (child) => child instanceof THREE.Mesh && child.visible + ) + // The render set must put at least the root covers on screen. + expect(visibleTiles.length).toBeGreaterThan(0) + }) + + it('re-selects after zooming and keeps the view covered', async () => { + await mount() + settleAllLoads() + const loadedCount = requests.length + expect(loadedCount).toBeGreaterThan(0) + + // Dolly out ~20% along the view axis, far beyond the 2% orbit-distance + // trigger, run a frame, then let the trailing-throttle timer fire so the + // re-selection traversal completes. + camera.position.multiplyScalar(1.2) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld() + runFrame() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 250)) + }) + + // Coarser levels are already cached from the initial full-chain load, so + // zooming out may legitimately issue no new request; what must hold is + // that the view stays covered by visible tile meshes. + const group = scene.children.find((child) => child.name === 'TileLayer') + const visibleTiles = (group as THREE.Group).children.filter( + (child) => child instanceof THREE.Mesh && child.visible + ) + expect(visibleTiles.length).toBeGreaterThan(0) + }) +}) diff --git a/src/__tests__/TilePyramid.test.ts b/src/__tests__/TilePyramid.test.ts new file mode 100644 index 0000000..886421c --- /dev/null +++ b/src/__tests__/TilePyramid.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from 'vitest' +import { TilePyramid, TileState, tileKey } from '../crs/TilePyramid' +import type { GroundRect, TileNodeBase } from '../crs/TilePyramid' +import type { TileIndex } from '../crs/Tiles' + +/** + * Deterministic local-space model for the tests. Like the Web Mercator world, + * the ground is twice as wide as it is tall: 32 x 16 metres, covered by the + * single zoom-0 root tile. Each level halves both spans. Real projection + * maths is irrelevant here - only the quadtree behaviour of the selection is + * under test. + */ +const WORLD_W = 32 +const WORLD_H = 16 +const WORLD_RECT: GroundRect = { minX: 0, maxX: WORLD_W, minZ: 0, maxZ: WORLD_H } + +const boundsOf = (index: TileIndex): GroundRect => { + const spanX = WORLD_W / 2 ** index.z + const spanY = WORLD_H / 2 ** index.z + return { + minX: index.x * spanX, + maxX: (index.x + 1) * spanX, + minZ: index.y * spanY, + maxZ: (index.y + 1) * spanY + } +} + +interface TestNode extends TileNodeBase { + bounds: GroundRect | null +} + +const createNode = (index: TileIndex): TestNode => ({ + index, + state: TileState.Idle, + attempts: 0, + lastSeen: 0, + queued: false, + rendering: false, + bounds: null +}) + +const createPyramid = (): TilePyramid => + new TilePyramid({ createNode, maxLoadAttempts: 2 }) + +/** Mark a node as if its image had finished downloading. */ +const load = (pyramid: TilePyramid, index: TileIndex): void => { + pyramid.getOrCreate(index).state = TileState.Loaded +} + +/** Every rendered tile covering one sample point on the ground. */ +const coveringAt = (pyramid: TilePyramid, x: number, z: number): TestNode[] => { + const covers: TestNode[] = [] + pyramid.nodes.forEach((node) => { + if (!node.rendering) return + const bounds = boundsOf(node.index) + if (x >= bounds.minX && x < bounds.maxX && z >= bounds.minZ && z < bounds.maxZ) { + covers.push(node) + } + }) + return covers +} + +/** + * Invariant 1: every sample point of the rectangle is covered by at least one + * rendered tile (no holes / black regions). + */ +const expectRectCovered = (pyramid: TilePyramid, rect: GroundRect): void => { + const uncovered: string[] = [] + for (let x = rect.minX + 0.5; x < rect.maxX; x += 1) { + for (let z = rect.minZ + 0.5; z < rect.maxZ; z += 1) { + if (coveringAt(pyramid, x, z).length === 0) uncovered.push(`(${x},${z})`) + } + } + expect(uncovered, `uncovered ground: ${uncovered.join(', ')}`).toEqual([]) +} + +/** + * Invariant 2: no sample point of the world is covered by more than one zoom + * level (no overlapping levels of detail at the same ground point). + */ +const expectNoLevelOverlap = (pyramid: TilePyramid): void => { + const offenders: string[] = [] + for (let x = 0.5; x < WORLD_W; x += 1) { + for (let z = 0.5; z < WORLD_H; z += 1) { + const levels = new Set(coveringAt(pyramid, x, z).map((node) => node.index.z)) + if (levels.size > 1) offenders.push(`(${x},${z}): z=[${Array.from(levels).join(',')}]`) + } + } + expect(offenders, `overlapping levels: ${offenders.join(', ')}`).toEqual([]) +} + +describe('TilePyramid', () => { + it('creates the single zoom-0 root covering the world', () => { + const pyramid = createPyramid() + expect(pyramid.nodes.size).toBe(1) + expect(boundsOf({ z: 0, x: 0, y: 0 })).toEqual(WORLD_RECT) + }) + + it('covers the rectangle with the root tile before anything else loads', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 3 }) + load(pyramid, { z: 0, x: 0, y: 0 }) + pyramid.renderSet() + expectRectCovered(pyramid, WORLD_RECT) + expectNoLevelOverlap(pyramid) + }) + + it('refines atomically: a parent renders until all needed children load', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 2 }) + // Root loaded, nothing else: the root must render. + pyramid.pendingIdeal().forEach((node) => { + if (node.index.z === 0) node.state = TileState.Loaded + }) + pyramid.renderSet() + expectRectCovered(pyramid, WORLD_RECT) + expect(pyramid.getOrCreate({ z: 0, x: 0, y: 0 }).rendering).toBe(true) + + // Load every level-1 tile except one: the root must keep rendering. + pyramid.pendingIdeal().forEach((node) => { + if (node.index.z === 1 && tileKey(node.index) !== '1_1_1') { + node.state = TileState.Loaded + } + }) + pyramid.renderSet() + expectRectCovered(pyramid, WORLD_RECT) + expect(pyramid.getOrCreate({ z: 0, x: 0, y: 0 }).rendering).toBe(true) + + // The last child arrives: the root hands over to all children at once. + load(pyramid, { z: 1, x: 1, y: 1 }) + pyramid.renderSet() + expectRectCovered(pyramid, WORLD_RECT) + expectNoLevelOverlap(pyramid) + expect(pyramid.getOrCreate({ z: 0, x: 0, y: 0 }).rendering).toBe(false) + expect(pyramid.getOrCreate({ z: 1, x: 1, y: 1 }).rendering).toBe(true) + }) + + it('never leaves a hole when a tile fails permanently - the ancestor covers it', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 3 }) + // Everything loads except one level-3 tile that exhausts its retries. + pyramid.pendingIdeal().forEach((node) => { + if (tileKey(node.index) === '3_5_2') { + node.state = TileState.Failed + node.attempts = 2 + } else { + node.state = TileState.Loaded + } + }) + pyramid.renderSet() + expectRectCovered(pyramid, WORLD_RECT) + expectNoLevelOverlap(pyramid) + expect(pyramid.getOrCreate({ z: 3, x: 5, y: 2 }).rendering).toBe(false) + // Its level-2 parent paints the footprint instead. + expect(pyramid.getOrCreate({ z: 2, x: 2, y: 1 }).rendering).toBe(true) + }) + + it('renders an early-arriving descendant while the root is still loading', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 3 }) + // The root is still in flight; a deep tile lands out of order. + pyramid.getOrCreate({ z: 0, x: 0, y: 0 }).state = TileState.Loading + load(pyramid, { z: 2, x: 1, y: 1 }) + pyramid.renderSet() + // The out-of-order tile paints its own footprint instead of going dark. + expect(pyramid.getOrCreate({ z: 2, x: 1, y: 1 }).rendering).toBe(true) + expect(coveringAt(pyramid, 12.5, 4.5).length).toBe(1) + }) + + it('keeps a panned-into region covered by cached ancestors', () => { + const pyramid = createPyramid() + const westHalf: GroundRect = { minX: 0, maxX: WORLD_W / 2, minZ: 0, maxZ: WORLD_H } + pyramid.selectIdeal({ rect: westHalf, boundsOf, maxZoom: 3 }) + pyramid.pendingIdeal().forEach((node) => { + node.state = TileState.Loaded + }) + pyramid.renderSet() + expectRectCovered(pyramid, westHalf) + + // Pan the window to the east half: new tiles are requested but only the + // coarse levels have arrived, so they must cover the new ground. + const eastHalf: GroundRect = { minX: WORLD_W / 2, maxX: WORLD_W, minZ: 0, maxZ: WORLD_H } + pyramid.selectIdeal({ rect: eastHalf, boundsOf, maxZoom: 3 }) + pyramid.pendingIdeal().forEach((node) => { + if (node.index.z >= 2) return + node.state = TileState.Loaded + }) + pyramid.renderSet() + expectRectCovered(pyramid, eastHalf) + }) + + it('supports mixed detail levels without holes or overlaps (SSE-style refinement)', () => { + const pyramid = createPyramid() + // Refine only the west half of the world, like a near/far SSE gradient. + const shouldRefine = (index: TileIndex): boolean => { + const bounds = boundsOf(index) + return bounds.minX < WORLD_W / 2 + } + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 3, shouldRefine }) + pyramid.pendingIdeal().forEach((node) => { + node.state = TileState.Loaded + }) + pyramid.renderSet() + expectRectCovered(pyramid, WORLD_RECT) + expectNoLevelOverlap(pyramid) + // The far (east) side stays coarse at level 1 while the near (west) side + // is refined. + expect(pyramid.getOrCreate({ z: 1, x: 1, y: 1 }).rendering).toBe(true) + expect(pyramid.getOrCreate({ z: 3, x: 0, y: 0 }).rendering).toBe(true) + }) + + it('stops refining at maxZoom even when the refinement test always passes', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ + rect: WORLD_RECT, + boundsOf, + maxZoom: 2, + shouldRefine: () => true + }) + let deepest = 0 + pyramid.nodes.forEach((node) => { + deepest = Math.max(deepest, node.index.z) + }) + expect(deepest).toBe(2) + }) + + it('returns pending tiles coarse-first and retries failures within budget', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 2 }) + const failed = pyramid.getOrCreate({ z: 1, x: 0, y: 0 }) + failed.state = TileState.Failed + failed.attempts = 1 + const exhausted = pyramid.getOrCreate({ z: 1, x: 1, y: 0 }) + exhausted.state = TileState.Failed + exhausted.attempts = 2 + + const pending = pyramid.pendingIdeal() + const pendingKeys = pending.map((node) => tileKey(node.index)) + // '1_0_0' retries (attempts < 2), '1_1_0' does not (budget exhausted). + expect(pendingKeys).toContain('1_0_0') + expect(pendingKeys).not.toContain('1_1_0') + // Coarse zooms come first. + const zooms = pending.map((node) => node.index.z) + expect([...zooms].sort((a, b) => a - b)).toEqual(zooms) + }) + + it('evicts only stale cache nodes, never the visible or in-flight ones', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 3 }) + pyramid.pendingIdeal().forEach((node) => { + node.state = TileState.Loaded + }) + pyramid.renderSet() + + // Shrink the window to the west half: old east-side mid-level tiles are + // stale cache (they stopped rendering once their children took over), and + // the reselection also created never-seen siblings outside the window. + const westHalf: GroundRect = { minX: 0, maxX: WORLD_W / 2, minZ: 0, maxZ: WORLD_H } + pyramid.selectIdeal({ rect: westHalf, boundsOf, maxZoom: 3 }) + const evicted = pyramid.evictStale(1, 2) + expect(evicted.length).toBeGreaterThan(0) + evicted.forEach((node) => { + expect(pyramid.nodes.has(tileKey(node.index))).toBe(false) + // The current ideal set is never evicted. + expect(node.lastSeen).not.toBe(2) + }) + // Whatever survives is either the current ideal set or still rendering. + pyramid.nodes.forEach((node) => { + expect(node.lastSeen === 2 || node.rendering).toBe(true) + }) + + // Under an extreme budget the visible set still survives: everything + // left is protected by lastSeen or by still rendering. + expect(pyramid.evictStale(0, 1).length).toBe(0) + }) + + it('clear returns every node for resource disposal', () => { + const pyramid = createPyramid() + pyramid.selectIdeal({ rect: WORLD_RECT, boundsOf, maxZoom: 1 }) + const all = pyramid.clear() + expect(all.length).toBeGreaterThan(0) + expect(pyramid.nodes.size).toBe(0) + }) +}) diff --git a/src/__tests__/Tiles.test.ts b/src/__tests__/Tiles.test.ts new file mode 100644 index 0000000..0d626ca --- /dev/null +++ b/src/__tests__/Tiles.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { + TILE_SIZE, + lngLatToTile, + tileToLngLatBounds, + tileToCorners, + selectTiles, + selectTilesInBounds, + tileUrl +} from '../crs/Tiles' + +describe('TILE_SIZE', () => { + it('matches the standard XYZ raster tile edge length', () => { + expect(TILE_SIZE).toBe(256) + }) +}) + +describe('lngLatToTile', () => { + it('maps every point on Earth to tile 0/0/0 at zoom 0', () => { + expect(lngLatToTile(0, 0, 0)).toEqual({ z: 0, x: 0, y: 0 }) + expect(lngLatToTile(116.4, 39.9, 0)).toEqual({ z: 0, x: 0, y: 0 }) + expect(lngLatToTile(-74, 40.7, 0)).toEqual({ z: 0, x: 0, y: 0 }) + }) + + it('locates the origin meridian at zoom 1', () => { + expect(lngLatToTile(0, 0, 1)).toEqual({ z: 1, x: 1, y: 1 }) + expect(lngLatToTile(-90, 0, 1)).toEqual({ z: 1, x: 0, y: 1 }) + expect(lngLatToTile(90, 0, 1)).toEqual({ z: 1, x: 1, y: 1 }) + }) + + it('wraps column indices across the antimeridian', () => { + expect(lngLatToTile(180, 0, 1)).toEqual({ z: 1, x: 0, y: 1 }) + expect(lngLatToTile(-180, 0, 1)).toEqual({ z: 1, x: 0, y: 1 }) + }) + + it('clamps polar latitudes to the Web Mercator limit', () => { + expect(lngLatToTile(0, 88, 2)).toEqual({ z: 2, x: 2, y: 0 }) + expect(lngLatToTile(0, -88, 2)).toEqual({ z: 2, x: 2, y: 3 }) + }) + + it('rejects invalid zoom levels', () => { + expect(() => lngLatToTile(0, 0, -1)).toThrow(RangeError) + expect(() => lngLatToTile(0, 0, 1.5)).toThrow(RangeError) + }) +}) + +describe('tileToLngLatBounds', () => { + it('returns the full Web Mercator world for the zoom 0 tile', () => { + const bounds = tileToLngLatBounds({ z: 0, x: 0, y: 0 }) + expect(bounds.west).toBeCloseTo(-180, 10) + expect(bounds.east).toBeCloseTo(180, 10) + expect(bounds.north).toBeCloseTo(85.05112878, 6) + expect(bounds.south).toBeCloseTo(-85.05112878, 6) + }) + + it('splits the world into four equal-width quadrants at zoom 1', () => { + const nw = tileToLngLatBounds({ z: 1, x: 0, y: 0 }) + expect(nw.west).toBeCloseTo(-180, 10) + expect(nw.east).toBeCloseTo(0, 10) + expect(nw.north).toBeCloseTo(85.05112878, 6) + expect(nw.south).toBeCloseTo(0, 10) + }) + + it('is the inverse of lngLatToTile for points away from edges', () => { + const samples: [number, number, number][] = [ + [116.4074, 39.9042, 10], + [-74.006, 40.7128, 8], + [139.6917, 35.6895, 12], + [-0.1276, 51.5074, 6] + ] + samples.forEach(([lng, lat, zoom]) => { + const index = lngLatToTile(lng, lat, zoom) + const bounds = tileToLngLatBounds(index) + expect(lng).toBeGreaterThanOrEqual(bounds.west) + expect(lng).toBeLessThan(bounds.east) + expect(lat).toBeLessThanOrEqual(bounds.north) + expect(lat).toBeGreaterThan(bounds.south) + }) + }) + + it('wraps out-of-range column and row indices', () => { + const count = 2 ** 2 + expect(tileToLngLatBounds({ z: 2, x: count, y: 0 }).west).toBeCloseTo(-180, 10) + expect(tileToLngLatBounds({ z: 2, x: 0, y: count }).south).toBeCloseTo(-85.05112878, 6) + }) +}) + +describe('tileToCorners', () => { + it('returns corners in NW/NE/SE/SW reading order', () => { + const corners = tileToCorners({ z: 1, x: 1, y: 1 }) + expect(corners.nw.lng).toBeCloseTo(0, 10) + expect(corners.nw.lat).toBeCloseTo(0, 10) + expect(corners.ne.lng).toBeCloseTo(180, 10) + expect(corners.ne.lat).toBeCloseTo(0, 10) + expect(corners.se.lng).toBeCloseTo(180, 10) + expect(corners.se.lat).toBeCloseTo(-85.05112878, 6) + expect(corners.sw.lng).toBeCloseTo(0, 10) + expect(corners.sw.lat).toBeCloseTo(-85.05112878, 6) + }) +}) + +describe('selectTiles', () => { + it('returns no tiles for empty coverage', () => { + expect(selectTiles([], 5)).toEqual([]) + }) + + it('selects the single containing tile for one point', () => { + expect(selectTiles([{ lng: 116.4, lat: 39.9 }], 0)).toEqual([{ z: 0, x: 0, y: 0 }]) + }) + + it('fills the tile bounding rectangle between two points', () => { + const tiles = selectTiles( + [ + { lng: 116.3, lat: 39.95 }, + { lng: 116.5, lat: 39.85 } + ], + 12 + ) + const nw = lngLatToTile(116.3, 39.95, 12) + const se = lngLatToTile(116.5, 39.85, 12) + const expectedColumns = se.x - nw.x + 1 + const expectedRows = se.y - nw.y + 1 + expect(tiles.length).toBe(expectedColumns * expectedRows) + expect(new Set(tiles.map((tile) => `${tile.x}/${tile.y}`)).size).toBe(tiles.length) + tiles.forEach((tile) => { + expect(tile.z).toBe(12) + expect(tile.x).toBeGreaterThanOrEqual(nw.x) + expect(tile.x).toBeLessThanOrEqual(se.x) + expect(tile.y).toBeGreaterThanOrEqual(nw.y) + expect(tile.y).toBeLessThanOrEqual(se.y) + }) + }) + + it('is datum-neutral: shifted coordinates still select a coherent rectangle', () => { + const tiles = selectTiles( + [ + { lng: 116.3, lat: 39.95 }, + { lng: 116.305, lat: 39.945 } + ], + 14 + ) + expect(tiles.length).toBeGreaterThan(0) + tiles.forEach((tile) => expect(tile.z).toBe(14)) + }) + + it('rejects invalid zoom levels', () => { + expect(() => selectTiles([{ lng: 0, lat: 0 }], -2)).toThrow(RangeError) + }) +}) + +describe('selectTilesInBounds', () => { + it('selects every tile intersecting the bounding rectangle', () => { + const zoom = 12 + const west = 116.3 + const south = 39.85 + const east = 116.5 + const north = 39.95 + const tiles = selectTilesInBounds(west, south, east, north, zoom) + const nw = lngLatToTile(west, north, zoom) + const se = lngLatToTile(east, south, zoom) + expect(tiles.length).toBe((se.x - nw.x + 1) * (se.y - nw.y + 1)) + expect(new Set(tiles.map((tile) => `${tile.x}/${tile.y}`)).size).toBe(tiles.length) + tiles.forEach((tile) => { + expect(tile.z).toBe(zoom) + expect(tile.x).toBeGreaterThanOrEqual(nw.x) + expect(tile.x).toBeLessThanOrEqual(se.x) + expect(tile.y).toBeGreaterThanOrEqual(nw.y) + expect(tile.y).toBeLessThanOrEqual(se.y) + }) + }) + + it('expands the rectangle by padding tiles on every side', () => { + const zoom = 10 + const west = 116.3 + const south = 39.85 + const east = 116.35 + const north = 39.9 + const nw = lngLatToTile(west, north, zoom) + const se = lngLatToTile(east, south, zoom) + const tiles = selectTilesInBounds(west, south, east, north, zoom, 1) + expect(tiles.length).toBe((se.x - nw.x + 3) * (se.y - nw.y + 3)) + tiles.forEach((tile) => { + expect(tile.x).toBeGreaterThanOrEqual(nw.x - 1) + expect(tile.x).toBeLessThanOrEqual(se.x + 1) + expect(tile.y).toBeGreaterThanOrEqual(nw.y - 1) + expect(tile.y).toBeLessThanOrEqual(se.y + 1) + }) + }) + + it('wraps columns when the rectangle crosses the antimeridian', () => { + const zoom = 3 + const west = 170 + const east = -170 + const tiles = selectTilesInBounds(west, -10, east, 10, zoom) + const nw = lngLatToTile(west, 10, zoom) + const se = lngLatToTile(east, -10, zoom) + const expectedColumns = ((se.x - nw.x + 2 ** zoom) % 2 ** zoom) + 1 + expect(tiles.length).toBe(expectedColumns * (se.y - nw.y + 1)) + expect(tiles.map((tile) => tile.x)).toContain(0) + expect(tiles.map((tile) => tile.x)).toContain(2 ** zoom - 1) + }) + + it('clamps rows at the poles without producing duplicates', () => { + const zoom = 2 + const tiles = selectTilesInBounds(-180, -90, 180, 90, zoom) + expect(new Set(tiles.map((tile) => `${tile.x}/${tile.y}`)).size).toBe(tiles.length) + tiles.forEach((tile) => { + expect(tile.x).toBeGreaterThanOrEqual(0) + expect(tile.x).toBeLessThan(2 ** zoom) + expect(tile.y).toBeGreaterThanOrEqual(0) + expect(tile.y).toBeLessThan(2 ** zoom) + }) + }) + + it('rejects invalid zoom levels', () => { + expect(() => selectTilesInBounds(0, 0, 1, 1, -1)).toThrow(RangeError) + expect(() => selectTilesInBounds(0, 0, 1, 1, 2.5)).toThrow(RangeError) + }) +}) + +describe('tileUrl', () => { + const index = { z: 3, x: 4, y: 5 } + + it('fills the z/x/y tokens', () => { + expect(tileUrl('https://tile.openstreetmap.org/{z}/{x}/{y}.png', index)).toBe( + 'https://tile.openstreetmap.org/3/4/5.png' + ) + }) + + it('cycles the {s} subdomain deterministically', () => { + const template = 'https://{s}.tiles.example.com/{z}/{x}/{y}.png' + expect(tileUrl(template, { z: 3, x: 0, y: 0 })).toBe('https://a.tiles.example.com/3/0/0.png') + expect(tileUrl(template, { z: 3, x: 0, y: 1 })).toBe('https://b.tiles.example.com/3/0/1.png') + expect(tileUrl(template, { z: 3, x: 0, y: 2 })).toBe('https://c.tiles.example.com/3/0/2.png') + expect(tileUrl(template, { z: 3, x: 0, y: 3 })).toBe('https://a.tiles.example.com/3/0/3.png') + }) + + it('accepts a custom subdomain list', () => { + expect(tileUrl('https://{s}.tiles.example.com/{z}/{x}/{y}.png', index, ['web0', 'web1'])).toBe( + 'https://web1.tiles.example.com/3/4/5.png' + ) + }) +}) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 115f3dd..e6fabac 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -15,6 +15,9 @@ const EXPECTED_EXPORTS = [ 'FlowLineMesh', 'GLTFLoader', 'GLTFLoaderAsync', + 'GeoJsonLayer', + 'GeoObject', + 'GeoReference', 'LightGradient', 'LightPillar', 'ModelRotator', @@ -30,10 +33,41 @@ const EXPECTED_EXPORTS = [ 'SkyBox', 'Snow', 'SweepLight', + 'TILE_SIZE', + 'TileLayer', 'WaveCircleMesh', + 'bd09ToGcj02', + 'bindSceneGeo', + 'collectGeometries', + 'convertDatum', + 'createGeoReference', + 'createLocalPlane', 'disposeDRACOLoader', 'disposeModel', - 'useScene' + 'ensureSceneGeo', + 'FitCamera', + 'fitCameraToBox', + 'fitCameraToGeoPoints', + 'fitCameraToObject', + 'fitCameraToPoints', + 'isGeoPoint', + 'flattenGeoJson', + 'fromWGS84', + 'gcj02ToBd09', + 'gcj02ToWgs84', + 'getSceneGeo', + 'lngLatToTile', + 'mercatorCRS', + 'mercatorProject', + 'mercatorUnproject', + 'selectTiles', + 'selectTilesInBounds', + 'tileToCorners', + 'tileToLngLatBounds', + 'tileUrl', + 'toWGS84', + 'useScene', + 'wgs84ToGcj02' ] as const describe('public API surface', () => { @@ -57,6 +91,9 @@ describe('public API surface', () => { 'FBXLoader', 'FlowLineMesh', 'GLTFLoader', + 'GeoObject', + 'GeoJsonLayer', + 'GeoReference', 'LightGradient', 'LightPillar', 'ModelRotator', @@ -69,13 +106,42 @@ describe('public API surface', () => { 'SkyBox', 'Snow', 'SweepLight', + 'TileLayer', 'WaveCircleMesh', 'useScene', 'disposeModel', 'disposeDRACOLoader', + 'FitCamera', + 'fitCameraToBox', + 'fitCameraToObject', + 'fitCameraToGeoPoints', + 'fitCameraToPoints', + 'isGeoPoint', + 'createGeoReference', + 'bindSceneGeo', + 'getSceneGeo', + 'ensureSceneGeo', + 'createLocalPlane', + 'mercatorProject', + 'mercatorUnproject', + 'convertDatum', + 'toWGS84', + 'fromWGS84', + 'wgs84ToGcj02', + 'gcj02ToWgs84', + 'gcj02ToBd09', + 'bd09ToGcj02', + 'flattenGeoJson', + 'collectGeometries', 'FBXLoaderAsync', 'GLTFLoaderAsync', - 'OBJLoaderAsync' + 'OBJLoaderAsync', + 'lngLatToTile', + 'tileToLngLatBounds', + 'tileToCorners', + 'selectTiles', + 'selectTilesInBounds', + 'tileUrl' ] const record = api as unknown as Record diff --git a/src/components/GeoJsonLayer.tsx b/src/components/GeoJsonLayer.tsx new file mode 100644 index 0000000..1471f84 --- /dev/null +++ b/src/components/GeoJsonLayer.tsx @@ -0,0 +1,233 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { useScene } from '../context/SceneContext' +import { flattenGeoJson } from '../crs/GeoJson' +import type { GeoJsonData, GeoJsonFeature, GeoJsonRingSet } from '../crs/GeoJson' +import type { DatumType } from '../crs/types' +import type { GeoReference } from '../crs/GeoReference' + +export interface GeoJsonStyle { + /** Fill color for polygons; set to null/transparent to render edges only. */ + fillColor?: THREE.ColorRepresentation + /** Edge color for polygons and color of line strings. */ + strokeColor?: THREE.ColorRepresentation + /** Marker color for points. */ + pointColor?: THREE.ColorRepresentation + /** Point marker size in world metres (diamond billboard via LineSegments). */ + pointSize?: number + /** Polygon extrusion height in metres; 0 renders a flat ground overlay. */ + extrudeHeight?: number + opacity?: number +} + +export interface GeoJsonLayerProps { + data: GeoJsonData + datum?: DatumType + style?: GeoJsonStyle + /** Per-feature style override; return null to skip the feature, undefined to keep the base style. */ + featureStyle?: (feature: GeoJsonFeature) => GeoJsonStyle | null | undefined + onReady?: (group: THREE.Group) => void + scene?: THREE.Scene +} + +const DEFAULT_STYLE: Required> & { + extrudeHeight: number +} = { + fillColor: 0x4a90d9, + strokeColor: 0x9fd0ff, + pointColor: 0xffd166, + pointSize: 20, + extrudeHeight: 0, + opacity: 0.85 +} + +function resolveStyle(base: GeoJsonStyle | undefined, override?: GeoJsonStyle | null): GeoJsonStyle { + return { ...DEFAULT_STYLE, ...(base ?? {}), ...(override ?? {}) } +} + +function colorOf(value: THREE.ColorRepresentation): THREE.Color { + return new THREE.Color(value) +} + +/** Rotate a planar ENU polygon shape onto the ground plane (extrusion along +Y). */ +function buildPolygonMesh( + rings: GeoJsonRingSet, + geo: GeoReference, + datum: DatumType, + style: GeoJsonStyle +): THREE.Mesh { + const shape = new THREE.Shape() + rings.outer.forEach((point, index) => { + const local = geo.toLocal(point, datum) + if (index === 0) shape.moveTo(local.x, -local.z) + else shape.lineTo(local.x, -local.z) + }) + + const holes = rings.holes.map((ring) => { + const path = new THREE.Path() + ring.forEach((point, index) => { + const local = geo.toLocal(point, datum) + if (index === 0) path.moveTo(local.x, -local.z) + else path.lineTo(local.x, -local.z) + }) + return path + }) + shape.holes.push(...holes) + + const height = style.extrudeHeight ?? 0 + const geometry = + height > 0 + ? new THREE.ExtrudeGeometry(shape, { depth: height, bevelEnabled: false }) + : new THREE.ShapeGeometry(shape) + geometry.rotateX(-Math.PI / 2) + + const material = new THREE.MeshBasicMaterial({ + color: colorOf(style.fillColor ?? DEFAULT_STYLE.fillColor), + transparent: (style.opacity ?? 1) < 1, + opacity: style.opacity ?? 1, + side: THREE.DoubleSide + }) + return new THREE.Mesh(geometry, material) +} + +const GeoJsonLayer = ({ data, datum, style, featureStyle, onReady, scene: propScene }: GeoJsonLayerProps) => { + const sceneContext = useScene() + const groupRef = useRef(null) + const effectiveDatum = datum ?? sceneContext?.geo?.datum + + useEffect(() => { + let cancelled = false + + const init = () => { + const scene = propScene || sceneContext?.scene + const geo = propScene ? undefined : sceneContext?.geo + if (!scene || !geo || !effectiveDatum) { + if (!cancelled) setTimeout(init, 100) + return + } + + const group = new THREE.Group() + group.name = 'GeoJsonLayer' + + const flat = flattenGeoJson(data) + const merged = resolveStyle(style) + + // Points: a single LineSegments draw call renders diamond billboards whose + // pixel size stays constant regardless of distance. + if (flat.points.length > 0) { + const positions: number[] = [] + flat.points.forEach((point) => { + const local = geo.toLocal(point, effectiveDatum) + const half = (merged.pointSize ?? DEFAULT_STYLE.pointSize) / 2 + positions.push( + local.x - half, + local.y, + local.z, + local.x + half, + local.y, + local.z, + local.x, + local.y - half, + local.z, + local.x, + local.y + half, + local.z + ) + }) + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + const material = new THREE.LineBasicMaterial({ + color: colorOf(merged.pointColor ?? DEFAULT_STYLE.pointColor) + }) + group.add(new THREE.LineSegments(geometry, material)) + } + + // Lines: separate segments per ring so unrelated rings never connect. + const linePositions: number[] = [] + flat.lines.forEach((ring) => { + for (let i = 0; i < ring.length - 1; i++) { + const a = geo.toLocal(ring[i], effectiveDatum) + const b = geo.toLocal(ring[i + 1], effectiveDatum) + linePositions.push(a.x, a.y, a.z, b.x, b.y, b.z) + } + }) + if (linePositions.length > 0) { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(linePositions, 3)) + const material = new THREE.LineBasicMaterial({ + color: colorOf(merged.strokeColor ?? DEFAULT_STYLE.strokeColor) + }) + group.add(new THREE.LineSegments(geometry, material)) + } + + // Polygons: per-feature geometry so property-driven fill colors and + // extrusion heights stay possible. Features are flattened individually to + // preserve the feature -> style mapping. + const features = + data.type === 'FeatureCollection' + ? data.features + : data.type === 'Feature' + ? [data] + : null + const featureGeometries: { rings: GeoJsonRingSet; override?: GeoJsonStyle | null }[] = [] + if (features) { + features.forEach((feature) => { + if (!feature.geometry) return + const override = featureStyle?.(feature) + if (override === null) return + const perFeature = flattenGeoJson(feature) + perFeature.polygons.forEach((rings) => featureGeometries.push({ rings, override })) + }) + } else { + flat.polygons.forEach((rings) => featureGeometries.push({ rings })) + } + featureGeometries.forEach(({ rings, override }) => { + const polygonStyle = resolveStyle(style, override) + group.add(buildPolygonMesh(rings, geo, effectiveDatum, polygonStyle)) + + // A stroked outline makes flat fills readable on dark backgrounds. + const outline: number[] = [] + const ringList = [rings.outer, ...rings.holes] + ringList.forEach((ring) => { + for (let i = 0; i < ring.length; i++) { + const a = geo.toLocal(ring[i], effectiveDatum) + const b = geo.toLocal(ring[(i + 1) % ring.length], effectiveDatum) + outline.push(a.x, a.y + 0.05, a.z, b.x, b.y + 0.05, b.z) + } + }) + const outlineGeometry = new THREE.BufferGeometry() + outlineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(outline, 3)) + const outlineMaterial = new THREE.LineBasicMaterial({ + color: colorOf(polygonStyle.strokeColor ?? DEFAULT_STYLE.strokeColor) + }) + group.add(new THREE.LineSegments(outlineGeometry, outlineMaterial)) + }) + + scene.add(group) + groupRef.current = group + onReady?.(group) + } + + init() + + return () => { + cancelled = true + const group = groupRef.current + if (group) { + group.removeFromParent() + group.traverse((child) => { + const obj = child as THREE.LineSegments | THREE.Mesh + obj.geometry?.dispose() + const material = (obj as THREE.Mesh).material as THREE.Material | undefined + material?.dispose() + }) + groupRef.current = null + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [propScene, sceneContext?.scene, sceneContext?.geo, data, effectiveDatum, style, featureStyle]) + + return null +} + +export default GeoJsonLayer diff --git a/src/components/GeoObject.tsx b/src/components/GeoObject.tsx new file mode 100644 index 0000000..0fd8181 --- /dev/null +++ b/src/components/GeoObject.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { useScene } from '../context/SceneContext' +import { ensureSceneGeo } from '../crs/SceneGeo' +import type { DatumType, GeoPoint } from '../crs/types' + +export interface GeoObjectProps { + /** Geographic position of the anchor, in the datum declared by `datum`. */ + coordinate: GeoPoint + /** Datum of the input coordinate. Defaults to the Scene's configured datum. */ + datum?: DatumType + /** + * Existing object to anchor. Ownership stays with the caller: the component + * only reparents it and restores the original parent on unmount. When + * omitted, an empty Group is created and handed to `onReady`. + */ + object?: THREE.Object3D + /** Called once with the anchored object (created Group or supplied object). */ + onReady?: (object: THREE.Object3D) => void + /** Optional explicit scene; defaults to the Scene provided by context. */ + scene?: THREE.Scene +} + +function disposeObject(object: THREE.Object3D): void { + object.traverse((child) => { + const mesh = child as THREE.Mesh + mesh.geometry?.dispose() + const material = mesh.material as THREE.Material | THREE.Material[] | undefined + if (Array.isArray(material)) { + material.forEach((m) => m.dispose()) + } else { + material?.dispose() + } + }) +} + +const GeoObject = ({ + coordinate, + datum, + object: propObject, + onReady, + scene: propScene +}: GeoObjectProps) => { + const sceneContext = useScene() + const groupRef = useRef(null) + const ownedRef = useRef(false) + const originalParentRef = useRef(null) + + useEffect(() => { + let cancelled = false + + const init = () => { + const scene = propScene || sceneContext?.scene + if (!scene) { + // The Scene context is published asynchronously after the renderer + // starts; retry briefly in the same way as the model loaders. + if (!cancelled) setTimeout(init, 100) + return + } + + const object = propObject ?? new THREE.Group() + ownedRef.current = !propObject + originalParentRef.current = object.parent + // R3L scenes already carry setPosition; a user-supplied bare scene is + // bound here without a reference, so lng/lat/alt fall back to local + // metres exactly as the previous manual positioning branch did. + ensureSceneGeo(scene) + scene.setPosition(object, coordinate, datum) + scene.add(object) + groupRef.current = object + onReady?.(object) + } + + init() + + return () => { + cancelled = true + const object = groupRef.current + if (object) { + // Restore the caller's parent when an external object was supplied; + // dispose everything when the Group was created internally. + const originalParent = originalParentRef.current + if (originalParent && !ownedRef.current) { + originalParent.add(object) + } else { + object.removeFromParent() + if (ownedRef.current) disposeObject(object) + } + } + groupRef.current = null + originalParentRef.current = null + ownedRef.current = false + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [propScene, sceneContext?.scene, propObject]) + + // Keep the anchor position in sync with prop changes without rebuilding. + useEffect(() => { + const object = groupRef.current + const scene = propScene || sceneContext?.scene + if (object && scene) { + ensureSceneGeo(scene) + scene.setPosition(object, coordinate, datum) + } + }, [coordinate, datum, propScene, sceneContext?.scene]) + + return null +} + +export default GeoObject diff --git a/src/components/Scene.tsx b/src/components/Scene.tsx index 448d003..d16b92d 100644 --- a/src/components/Scene.tsx +++ b/src/components/Scene.tsx @@ -3,12 +3,14 @@ import * as THREE from 'three' import { OrbitControls } from 'three-stdlib' import { generateUUID } from '../utils/UUID' import createScene from '../utils/Scene' -import CameraUtil from '../utils/Camera' +import CameraUtil, { GIS_CAMERA_FAR } from '../utils/Camera' import LightUtil from '../utils/Light' import Renderer, { RendererType } from '../utils/Renderer' import AxesHelperUtil from '../utils/AxesHelper' import Controls from '../utils/Controls' import Picker, { PickCallback } from '../utils/Picker' +import { GeoReference } from '../crs/GeoReference' +import type { AxesMapping, CrsCode, DatumType, GeoPoint } from '../crs/types' import { SceneContext, SceneComponents, @@ -17,6 +19,14 @@ import { R3LRenderer } from '../context/SceneContext' +export interface GridHelperOptions { + size?: number + divisions?: number + colorCenterLine?: THREE.ColorRepresentation + colorGrid?: THREE.ColorRepresentation + y?: number +} + interface SceneProps { modelValue?: THREE.Scene renderer?: R3LRenderer @@ -26,8 +36,13 @@ interface SceneProps { camera?: THREE.PerspectiveCamera light?: THREE.Object3D axesHelper?: THREE.AxesHelper | boolean - gridHelper?: THREE.GridHelper | boolean + gridHelper?: THREE.GridHelper | GridHelperOptions | boolean controls?: OrbitControls + geo?: GeoReference + origin?: GeoPoint + crs?: CrsCode + datum?: DatumType + axes?: AxesMapping onCreated?: (scene: THREE.Scene, components: SceneComponents) => void onBeforeFrame?: CallbackFrame onFrame?: CallbackFrame @@ -51,6 +66,11 @@ const SceneComponent = ({ axesHelper: propAxesHelper, gridHelper: propGridHelper, controls: propControls, + geo: propGeo, + origin, + crs, + datum, + axes, onCreated, onBeforeFrame, onAfterFrame, @@ -128,8 +148,31 @@ const SceneComponent = ({ let currentCamera: THREE.PerspectiveCamera | null = null const start = async () => { + // A GeoReference always exists: when no GIS props are supplied it is the + // default WGS84/equirectangular reference centred at (0, 0), which is a + // no-op for every non-geographic scene. + const geo = + propGeo ?? + new GeoReference(origin, { + ...(crs !== undefined ? { crs } : {}), + ...(datum !== undefined ? { datum } : {}), + ...(axes !== undefined ? { axes } : {}) + }) + + // A scene built from any geographic prop works in local metres around a + // geographic origin and spans kilometres, so both the default camera far + // plane and the built-in grid need kilometre-range defaults. A scene with + // none of these props keeps the compact generic defaults. + const isGisScene = + propGeo !== undefined || + origin !== undefined || + crs !== undefined || + datum !== undefined || + axes !== undefined + currentRenderer = propRenderer || Renderer(rendererType) - currentCamera = propCamera || CameraUtil(container) + currentCamera = + propCamera || CameraUtil(container, isGisScene ? { far: GIS_CAMERA_FAR } : undefined) currentLight = propLight || LightUtil() let currentAxesHelper: THREE.AxesHelper | undefined if (propAxesHelper === false) { @@ -139,14 +182,42 @@ const SceneComponent = ({ } else { currentAxesHelper = AxesHelperUtil() } + // A GIS scene works in local metres around a geographic origin, so the + // generic 20 m grid is far too small and its y = 0.01 plane is hidden + // under the opaque tile quads (y = 0.05). In that case scale the + // built-in grid to the kilometre range (50 m cells) and lift it just + // above the map plane; non-GIS scenes keep the compact grey grid. + const defaultGridOptions: Required = isGisScene + ? { + size: 2000, + divisions: 40, + colorCenterLine: 0x4a6fa5, + colorGrid: 0x2f3e5c, + y: 0.1 + } + : { + size: 20, + divisions: 20, + colorCenterLine: 0xbbbbbb, + colorGrid: 0xdddddd, + y: 0.01 + } let currentGridHelper: THREE.GridHelper | undefined if (propGridHelper === false) { currentGridHelper = undefined } else if (propGridHelper instanceof THREE.GridHelper) { currentGridHelper = propGridHelper } else { - currentGridHelper = new THREE.GridHelper(20, 20, 0xbbbbbb, 0xdddddd) - currentGridHelper.position.y = 0.01 + // A plain object customises the built-in grid (any field falls back + // to the scene-aware defaults above); omitting the prop uses those + // defaults, and `true` is treated the same. + const options = propGridHelper === true ? undefined : propGridHelper + const { size, divisions, colorCenterLine, colorGrid, y } = { + ...defaultGridOptions, + ...(options ?? {}) + } + currentGridHelper = new THREE.GridHelper(size, divisions, colorCenterLine, colorGrid) + currentGridHelper.position.y = y } currentControls = propControls || Controls(currentCamera, currentRenderer) @@ -154,14 +225,22 @@ const SceneComponent = ({ callbackFrameRef.current(renderer, scene, components) } - const beforeFrame = (renderer: R3LRenderer, scene: THREE.Scene, components: SceneComponents) => { + const beforeFrame = ( + renderer: R3LRenderer, + scene: THREE.Scene, + components: SceneComponents + ) => { onBeforeFrame?.(renderer, scene, components) beforeFrameChildrenRef.current?.forEach((beforeFrameChild) => { beforeFrameChild?.(renderer, scene, components) }) } - const afterFrame = (renderer: R3LRenderer, scene: THREE.Scene, components: SceneComponents) => { + const afterFrame = ( + renderer: R3LRenderer, + scene: THREE.Scene, + components: SceneComponents + ) => { onAfterFrame?.(renderer, scene, components) afterFrameChildrenRef.current?.forEach((afterFrameChild) => { afterFrameChild?.(renderer, scene, components) @@ -180,7 +259,8 @@ const SceneComponent = ({ light: light, axesHelper: currentAxesHelper, gridHelper: currentGridHelper, - controls: controls + controls: controls, + geo: geo } const { scene, dispose: disposeScene } = await createScene( @@ -231,6 +311,7 @@ const SceneComponent = ({ renderer: currentRenderer, scene: scene, sceneComponents: sceneComponents, + geo: geo, picker: picker, setFrame: (callback: CallbackFrame) => { callbackFrameRef.current = callback diff --git a/src/components/TileLayer.tsx b/src/components/TileLayer.tsx new file mode 100644 index 0000000..6870331 --- /dev/null +++ b/src/components/TileLayer.tsx @@ -0,0 +1,1206 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { useScene } from '../context/SceneContext' +import { lngLatToTile, selectTiles, tileToCorners, tileUrl } from '../crs/Tiles' +import { TilePyramid, TileState, tileKey } from '../crs/TilePyramid' +import type { GroundRect, TileNodeBase } from '../crs/TilePyramid' +import { MAX_GROUND_RANGE_FACTOR, sampleVisibleGround } from '../utils/GroundSampling' +import type { TileIndex } from '../crs/Tiles' +import type { DatumType, GeoPoint } from '../crs/types' +import type { GeoReference } from '../crs/GeoReference' + +export interface TileLayerProps { + /** + * URL template of the XYZ tile source. Supports {z}, {x}, {y} and an + * optional {s} subdomain placeholder, e.g. + * 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'. + */ + url: string + /** Slippy-map zoom level (non-negative integer). */ + zoom: number + /** + * Static geographic coverage: the bounding rectangle of the points is + * filled once with tiles at the given zoom. Ignored when `followCamera` is + * enabled (the default), which derives coverage from the camera instead. + */ + points?: GeoPoint[] + /** + * Keep the layer centred on the camera: every frame the visible ground + * rectangle (camera frustum projected onto the ground plane) is tiled, and + * tiles entering/leaving it are loaded/disposed while the camera moves. + * Requires a Scene context camera; falls back to `points` when an explicit + * `scene` without context is supplied. Defaults to true. + */ + followCamera?: boolean + /** + * Datum of the tile imagery (and of `points` in static mode). WGS84 for OSM + * and most international services, GCJ02 for AMap (Gaode)/Tencent. Defaults + * to the Scene's configured datum. + */ + datum?: DatumType + /** + * Extra rings of tiles loaded beyond the visible ground rectangle so the map + * stays covered while panning. Defaults to 1. + */ + padding?: number + /** Optional {s} subdomain list for the URL template. Defaults to a/b/c. */ + subdomains?: string[] + /** Opacity of the tile images. Defaults to 1. */ + opacity?: number + /** Height in local metres at which the ground quads are laid. Defaults to 0.05. */ + y?: number + /** + * Air-perspective fading towards the horizon: scene fog plus a backing + * ground plane tinted to the background colour, both scaled with camera + * height. This hides the hard rectangular edge of the tiled area at + * oblique angles so the map dissolves into the background instead of + * floating over it. Disable with `fog={false}`. Defaults to true. + */ + fog?: boolean + /** + * Colour of the distance fog and backing ground plane. Defaults to the + * Scene's solid background colour, falling back to #1a1a2e. Set this to the + * Scene `bgColor` when the background is an image. + */ + groundColor?: THREE.ColorRepresentation + /** + * Safety cap on simultaneously retained tiles. Camera-following mode keeps + * the `maxTiles` closest to the camera instead of exceeding it; static + * `points` mode throws when the selection exceeds it. Defaults to 256. + */ + maxTiles?: number + /** + * Largest OrbitControls polar angle (radians from the positive y-axis) + * allowed while the layer follows the camera. Caps how far the view can tilt + * toward the horizon, where planar ground tiles degenerate into stretched + * ribbons. Only applied when context controls exist; a stricter existing + * limit is never loosened and the original value is restored on unmount. + * Defaults to 15 degrees above horizontal (PI/2 - 15deg). + */ + maxPolarAngle?: number + /** Called once with the layer group after it was added to the scene. */ + onReady?: (group: THREE.Group) => void + /** Called when one tile image fails to download; the tile stays hidden. */ + onTileError?: (index: TileIndex, url: string) => void + /** Optional explicit scene; defaults to the Scene provided by context. */ + scene?: THREE.Scene +} + +const DEFAULT_OPACITY = 1 +const DEFAULT_Y = 0.05 +const DEFAULT_MAX_TILES = 256 +const DEFAULT_PADDING = 1 +// The tiled ground range - and with it the fog extent and the dynamic +// far-plane fit - is scaled in camera heights: imagery is provided up to +// FOG_FAR_FACTOR heights of ground range from the point below the camera, and +// the fog is fully opaque exactly at that coverage edge (see applyAtmosphere). +// The backing plane fills the rest of the view through the fog at any oblique +// angle. +const FOG_FAR_FACTOR = MAX_GROUND_RANGE_FACTOR +const BACKING_HALF_SIZE_FACTOR = 40 +const DEFAULT_GROUND_COLOR = 0x1a1a2e +// MapLibre caps its camera pitch at 60 degrees from the vertical: on a flat +// (non-globe) map, tilting further means the tiled coverage and its fog can +// no longer stay ahead of the visible horizon, and planar ground quads +// stretch into blurred ribbons. While the layer follows the camera, +// OrbitControls is therefore constrained to the same polar-angle ceiling; a +// stricter existing limit is kept and the original value is restored on +// unmount. +const DEFAULT_MAX_POLAR_ANGLE = THREE.MathUtils.degToRad(60) +// Closest camera-to-target distance the layer allows while following the +// camera, as a fraction of the zoom-out cap: the camera can never dolly into +// ground-level content (markers, pins) or inside the imagery it looks at. +// Cesium exposes the same idea as minimumZoomDistance. +const MIN_DISTANCE_FACTOR = 0.05 +// Screen-space error, in CSS pixels, at which a tile is subdivided into its +// children. 256 means each 256-px tile is refined once it projects larger +// than a full screen tile - the same target used by Cesium/MapLibre. A larger +// value trades imagery sharpness for fewer requests. +const SSE_THRESHOLD_PX = 256 +// Hard ceiling on ideal (camera-facing) nodes one cover traversal may reach, +// guarding against an accidental massive selection at extreme parameters. +// Static `points` mode is bounded by its own leaf count instead. +const MAX_IDEAL_NODES = 6000 +// Number of concurrent image downloads shared by the whole layer. Public tile +// servers rate-limit bulk requests (OSM famously answers bursts with +// timeouts), so the layer stays within the browser's ~6 connections per host +// instead of overwhelming it. +const MAX_CONCURRENT_LOADS = 6 +// Hard ceiling on one tile download. A wedged request otherwise blocks its +// node for the browser's full TCP timeout (~20 s) with no retry, which reads +// as "tiles never load"; on timeout the node fails fast into the retry queue. +const TILE_LOAD_TIMEOUT_MS = 8000 +// Failed downloads are retried up to this many extra times, then the node +// stays idle and onTileError fires for every failed attempt. +const MAX_LOAD_ATTEMPTS = 2 +// During a continuous gesture the camera crosses the retile threshold on +// nearly every frame. Recomputing the cover that often churns the load queue, +// so traversals are coalesced to at most one per this many milliseconds, with +// a trailing traversal once the gesture settles. +const FILL_THROTTLE_MS = 120 +// Re-tile whenever the camera has rotated by at least this many radians even +// without translating, e.g. an orbit tilt whose arc stays inside the +// movement threshold. A stale mid-gesture selection must never be left frozen +// at the final (nearly horizontal) viewing angle. +const MAX_ORIENTATION_DELTA = THREE.MathUtils.degToRad(4) +// Cached tiles are evicted, least-recently-seen first, once the cache holds +// this multiple of maxTiles tiles. Only idle, non-queued nodes are evicted, +// so the visible layer and every pending download are always protected. +const CACHE_FACTOR = 2 +// Each tile quad is expanded by this fraction of its span so neighbouring +// quads physically overlap. The overlap strip samples just outside the tile +// image, where ClampToEdge repeats the border pixels, so both neighbours paint +// the same ground content across the seam: sub-pixel gaps between separate +// draw calls can never reveal the background between tiles. +const TILE_OVERLAP_RATIO = 0.01 +// Newly arrived imagery crossfades in over whatever is beneath it - its +// on-screen coarse parent during a level swap, the backing plane on a cold +// start - with roughly the same 300 ms ease MapLibre gives raster tiles, +// instead of popping in frame-hard. The parent stays rendered as an underlay +// until the fade completes; painter order (creation order, coarse first) +// draws the fading child on top of it. Cached re-appearances show instantly. +const FADE_DURATION_MS = 300 + +interface TileLayerNode extends TileNodeBase { + mesh: THREE.Mesh | null + material: THREE.MeshBasicMaterial | null + texture: THREE.Texture | null + // Lazily computed local-space ground bounds (plane at the tile height). + bounds: GroundRect | null + // Serial of the in-flight download attempt; callbacks from an earlier + // (timed-out) attempt of the same node are recognised and dropped by it. + loadToken?: number + // performance.now() stamp of the running fade-in; 0 when none. + fadeStart: number + // Whether the node has been fully on screen once; cached re-appearances + // (zooming back into an evicted area) show instantly. + shownOnce: boolean + // Key of the ancestor kept rendered beneath this node's running fade. + underlayKey?: string +} + +/** + * Build the ground quad for one tile. Each corner is projected through the + * scene's GeoReference in the tile source datum, which absorbs the non-linear + * GCJ02 shift per corner so GCJ02-encrypted imagery stays seamless. The quad + * is then expanded slightly around its centre (see TILE_OVERLAP_RATIO). + */ +function createTileGeometry( + index: TileIndex, + geo: GeoReference, + datum: DatumType, + height: number +): THREE.BufferGeometry { + const { nw, ne, se, sw } = tileToCorners(index) + const pNW = geo.toLocal(nw, datum) + const pNE = geo.toLocal(ne, datum) + const pSE = geo.toLocal(se, datum) + const pSW = geo.toLocal(sw, datum) + + const centreX = (pNW.x + pNE.x + pSE.x + pSW.x) / 4 + const centreZ = (pNW.z + pNE.z + pSE.z + pSW.z) / 4 + const scale = 1 + TILE_OVERLAP_RATIO + const expand = (point: { x: number; z: number }): { x: number; z: number } => ({ + x: centreX + (point.x - centreX) * scale, + z: centreZ + (point.z - centreZ) * scale + }) + const eNW = expand(pNW) + const eNE = expand(pNE) + const eSE = expand(pSE) + const eSW = expand(pSW) + + const positions = new Float32Array([ + eNW.x, + height, + eNW.z, + eNE.x, + height, + eNE.z, + eSE.x, + height, + eSE.z, + eSW.x, + height, + eSW.z + ]) + // UVs reach just beyond [0, 1] on the expanded border; the texture's + // ClampToEdge wrapping turns that into the edge pixels being stretched over + // the overlap strip instead of wrapping to the opposite side of the image. + const uvPad = TILE_OVERLAP_RATIO / 2 + const uvs = new Float32Array([ + -uvPad, + 1 + uvPad, + 1 + uvPad, + 1 + uvPad, + 1 + uvPad, + -uvPad, + -uvPad, + -uvPad + ]) + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2)) + geometry.setIndex([0, 1, 2, 0, 2, 3]) + return geometry +} + +/** Local-space centre of a tile's ground quad. */ +function boundsCenter(bounds: GroundRect): { x: number; z: number } { + return { + x: (bounds.minX + bounds.maxX) / 2, + z: (bounds.minZ + bounds.maxZ) / 2 + } +} + +/** + * Compute a tile's local-space ground bounds, in metres, from its geographic + * corners (without the render overlap). The result is cached on the node. + */ +function computeBounds( + node: TileLayerNode, + geo: GeoReference, + datum: DatumType +): GroundRect { + if (node.bounds) return node.bounds + const { nw, ne, se, sw } = tileToCorners(node.index) + const pNW = geo.toLocal(nw, datum) + const pNE = geo.toLocal(ne, datum) + const pSE = geo.toLocal(se, datum) + const pSW = geo.toLocal(sw, datum) + const bounds: GroundRect = { + minX: Math.min(pNW.x, pNE.x, pSE.x, pSW.x), + maxX: Math.max(pNW.x, pNE.x, pSE.x, pSW.x), + minZ: Math.min(pNW.z, pNE.z, pSE.z, pSW.z), + maxZ: Math.max(pNW.z, pNE.z, pSE.z, pSW.z) + } + node.bounds = bounds + return bounds +} + +const TileLayer = ({ + url, + zoom, + points, + followCamera = true, + datum, + padding = DEFAULT_PADDING, + subdomains, + opacity = DEFAULT_OPACITY, + y = DEFAULT_Y, + fog = true, + groundColor, + maxTiles = DEFAULT_MAX_TILES, + maxPolarAngle = DEFAULT_MAX_POLAR_ANGLE, + onReady, + onTileError, + scene: propScene +}: TileLayerProps) => { + const sceneContext = useScene() + const groupRef = useRef(null) + const loaderRef = useRef(null) + const pyramidRef = useRef | null>(null) + const opacityRef = useRef(opacity) + const fogEnabledRef = useRef(fog) + const groundColorRef = useRef(groundColor) + const onReadyRef = useRef(onReady) + const onTileErrorRef = useRef(onTileError) + const effectiveDatum = datum ?? sceneContext?.geo?.datum + + useEffect(() => { + opacityRef.current = opacity + fogEnabledRef.current = fog + groundColorRef.current = groundColor + onReadyRef.current = onReady + onTileErrorRef.current = onTileError + }) + + useEffect(() => { + pyramidRef.current?.nodes.forEach((node) => { + if (node.material) node.material.opacity = opacityRef.current + }) + }, [opacity]) + + useEffect(() => { + let cancelled = false + let disposeAfterFrame: (() => void) | undefined + let restoreControls: (() => void) | undefined + let trailingTimer = 0 + let cleanupAtmosphere: (() => void) | undefined + let disposeAtmosphereFrame: (() => void) | undefined + let disposeFadeFrame: (() => void) | undefined + const intervalCounters = { id: 0 } + + // Quadtree selection state: the ideal set per traversal, the atomic + // render set and the LRU cache all live in the TilePyramid. This effect + // only owns the Three.js resources and the download scheduling. + const pyramid = new TilePyramid({ + maxLoadAttempts: MAX_LOAD_ATTEMPTS, + createNode: (index) => ({ + index, + state: TileState.Idle, + attempts: 0, + lastSeen: 0, + queued: false, + rendering: false, + mesh: null, + material: null, + texture: null, + bounds: null, + fadeStart: 0, + shownOnce: false + }) + }) + pyramidRef.current = pyramid + const nodes = pyramid.nodes + let activeLoads = 0 + let loadSerial = 0 + + // Zoom-bucketed download queue: coarse buckets drain first so the layer + // streams from the root covers down; within one bucket the tile nearest + // the camera loads first. + const queueBuckets = new Map>() + + // Nodes with a running fade-in. One shared per-frame ticker advances + // them all - never one animation callback per tile. + const fading = new Set() + // Refcount per underlay key: a coarse ancestor kept visible beneath one + // or more crossfading children until their fades complete. + const underlayRefs = new Map() + // Fades need the Scene's per-frame hook; without a ticker the tiles + // would sit at opacity 0, so they must appear instantly instead. + const canFade = Boolean(sceneContext?.addAfterFrame) + + /** Drop one underlay reference and hide the ancestor once unreferenced. */ + const releaseUnderlay = (node: TileLayerNode): void => { + const key = node.underlayKey + if (!key) return + node.underlayKey = undefined + const refs = (underlayRefs.get(key) ?? 1) - 1 + if (refs > 0) { + underlayRefs.set(key, refs) + return + } + underlayRefs.delete(key) + const underlay = nodes.get(key) + if (underlay?.mesh && !underlay.rendering) underlay.mesh.visible = false + } + + /** Finish or cancel a node's fade and release its underlay. */ + const endFade = (node: TileLayerNode): void => { + if (!node.fadeStart) return + node.fadeStart = 0 + fading.delete(tileKey(node.index)) + releaseUnderlay(node) + } + + /** + * Advance every running fade-in once per frame. Finished, hidden or + * evicted nodes drop out of the set, so the ticker costs nothing once + * all imagery has settled. + */ + const tickFades = (): void => { + if (cancelled || fading.size === 0) return + const now = performance.now() + fading.forEach((key) => { + const node = nodes.get(key) + if (!node || !node.rendering || !node.material || !node.fadeStart) { + if (node) endFade(node) + else fading.delete(key) + return + } + const progress = Math.min((now - node.fadeStart) / FADE_DURATION_MS, 1) + // Ease-out cubic: quick ramp, gentle landing. + node.material.opacity = opacityRef.current * (1 - (1 - progress) ** 3) + if (progress >= 1) { + node.shownOnce = true + endFade(node) + } + }) + } + + const disposeNodeResources = (node: TileLayerNode) => { + if (node.mesh) { + node.mesh.removeFromParent() + node.mesh.geometry.dispose() + } + node.texture?.dispose() + node.material?.dispose() + endFade(node) + node.mesh = null + node.material = null + node.texture = null + } + + const init = () => { + if (cancelled) return + const scene = propScene || sceneContext?.scene + const geo = propScene ? undefined : sceneContext?.geo + if (!scene || !geo || !effectiveDatum) { + intervalCounters.id = window.setTimeout(init, 100) + return + } + + // Camera-driven tiling needs the context camera; an explicit scene prop + // without context can only use the static `points` coverage. + const contextCamera = sceneContext?.sceneComponents?.camera + const dynamic = followCamera && !propScene && contextCamera instanceof THREE.PerspectiveCamera + + if (!dynamic) { + if (!points || points.length === 0) return + const staticLeaves = selectTiles(points, zoom) + if (staticLeaves.length === 0) return + if (staticLeaves.length > maxTiles) { + throw new Error( + `TileLayer: selected ${staticLeaves.length} tiles at zoom ${zoom}, which exceeds the maxTiles limit of ${maxTiles}` + ) + } + } + + const group = new THREE.Group() + group.name = 'TileLayer' + // Decode tile imagery off the render thread: ImageBitmapLoader hands + // the PNG bytes to createImageBitmap, which decodes in a worker, so a + // burst of arrivals no longer decodes on the main thread the way + // TextureLoader's path does. Browsers without the API (and + // jsdom) fall back to TextureLoader. + const supportsWorkerDecode = + typeof createImageBitmap === 'function' && typeof fetch === 'function' + const loader: THREE.TextureLoader | THREE.ImageBitmapLoader = supportsWorkerDecode + ? new THREE.ImageBitmapLoader().setOptions({ imageOrientation: 'flipY' }) + : new THREE.TextureLoader() + loader.setCrossOrigin('anonymous') + loaderRef.current = loader + scene.add(group) + groupRef.current = group + onReadyRef.current?.(group) + if (canFade) { + disposeFadeFrame = sceneContext?.addAfterFrame?.(() => tickFades()) + } + + // Air perspective: height-scaled linear fog plus a large backing plane + // tinted to the background colour. Tiles end where fog becomes opaque + // (see MAX_GROUND_RANGE_FACTOR), so the rectangular tile edge and the + // map sides at oblique angles dissolve into the backing instead of + // floating over an empty background. Until the first tiles have landed + // the backing also fills the whole view, so the layer shows a solid + // ground colour instead of flashing the raw background on mount. + const resolveGroundColor = (): THREE.Color => { + const explicit = groundColorRef.current + if (explicit !== undefined) return new THREE.Color(explicit) + if (scene.background instanceof THREE.Color) return scene.background.clone() + return new THREE.Color(DEFAULT_GROUND_COLOR) + } + const groundTint = resolveGroundColor() + const backingGeometry = new THREE.PlaneGeometry(1, 1) + const backingMaterial = new THREE.MeshBasicMaterial({ + color: groundTint, + side: THREE.DoubleSide, + fog: false, + // The backing sits 0.01 m below the tile quads, but with the GIS + // near/far ratio (0.1/4000) one depth step at a few hundred metres is + // already tens of centimetres: larger than the gap, so the two layers + // z-fight and at certain zoom distances the tiles lose every fragment + // to the backing (the whole map turns into the background colour). + // The backing is the farthest thing in the layer, so it must not write + // depth at all: it can never occlude anything, and the transparent + // tile quads (drawn after opaque geometry) simply paint on top of it. + depthWrite: false + }) + const backing = new THREE.Mesh(backingGeometry, backingMaterial) + backing.name = 'TileLayer_Backing' + backing.rotation.x = -Math.PI / 2 + backing.position.y = y - 0.01 + backing.visible = true + group.add(backing) + + const previousFog = scene.fog + const layerFog = new THREE.Fog(groundTint, 1, 1000) + // Far plane the camera had before this layer extended it, plus the camera + // it belongs to; both are restored on unmount. + let baseCameraFar = -1 + let farPlaneCamera: THREE.PerspectiveCamera | null = null + + let hasVisibleTiles = false + + const applyAtmosphere = (camera: THREE.PerspectiveCamera | null, orbitDistance = -1) => { + const height = Math.max((camera?.position.y ?? y + 200) - y, 1) + const halfSize = height * BACKING_HALF_SIZE_FACTOR + backing.scale.set(halfSize * 2, halfSize * 2, 1) + backing.position.x = camera?.position.x ?? 0 + backing.position.z = camera?.position.z ?? 0 + // Cesium re-fits the frustum far plane to the camera altitude every + // frame. A fixed far plane is fine near the ground but, once the camera + // climbs, the tilted ground plane is clipped mid-frame: everything + // beyond the nadir clip distance vanishes and a hard band of empty + // background cuts across the view. Extend the far plane to cover the + // fog range plus the orbit distance, and only rebuild the projection + // when it actually moved. + if (camera && farPlaneCamera !== camera) { + baseCameraFar = camera.far + farPlaneCamera = camera + } + if (camera && baseCameraFar > 0) { + const requiredFar = Math.max( + baseCameraFar, + height * FOG_FAR_FACTOR * 1.5 + Math.max(orbitDistance, 0) + ) + if (Math.abs(camera.far - requiredFar) > requiredFar * 0.01) { + camera.far = requiredFar + camera.updateProjectionMatrix() + } + } + if (fogEnabledRef.current) { + scene.fog = layerFog + // Height-scaled fog breaks once the camera dollies: the fog band + // slides across the frame instead of staying at the edge of the + // tiled coverage. Anchor it to that edge instead: the tiles the + // sampler can still provide end at `rangeCap` metres of ground + // range, so the fog must be fully opaque exactly at the slant + // distance to that edge and start fading only past the frame's own + // content (never in front of the orbit target). + const horizontal = + orbitDistance > 0 + ? Math.sqrt(Math.max(orbitDistance * orbitDistance - height * height, 0)) + : 0 + const rangeCap = height * MAX_GROUND_RANGE_FACTOR + const edgeDistance = Math.sqrt( + Math.max(rangeCap - horizontal, 0) ** 2 + height * height + ) + layerFog.near = Math.max( + edgeDistance * 0.55, + orbitDistance > 0 ? orbitDistance * 1.1 : height * 2 + ) + layerFog.far = Math.max(layerFog.near * 1.05, edgeDistance) + backing.visible = true + return + } + if (scene.fog === layerFog) scene.fog = previousFog + // Without fog the backing is only a placeholder until imagery exists. + backing.visible = !hasVisibleTiles + } + cleanupAtmosphere = () => { + disposeAtmosphereFrame?.() + if (scene.fog === layerFog) scene.fog = previousFog + if (farPlaneCamera && baseCameraFar > 0 && farPlaneCamera.far !== baseCameraFar) { + farPlaneCamera.far = baseCameraFar + farPlaneCamera.updateProjectionMatrix() + } + backing.removeFromParent() + backingGeometry.dispose() + backingMaterial.dispose() + } + + const buildNodeMesh = (node: TileLayerNode) => { + const geometry = createTileGeometry(node.index, geo, effectiveDatum!, y) + const material = new THREE.MeshBasicMaterial({ + transparent: true, + opacity: opacityRef.current, + depthWrite: false, + side: THREE.DoubleSide + }) + const mesh = new THREE.Mesh(geometry, material) + mesh.name = `Tile_${node.index.z}_${node.index.x}_${node.index.y}` + // Coverage is chosen analytically from the camera frustum, so let the GPU + // draw every rendered quad: a single coarser ancestor can span kilometres + // where the bounding-sphere frustum test gains little and may drop quads. + mesh.frustumCulled = false + mesh.visible = false + if (node.texture && material) { + material.map = node.texture + material.needsUpdate = true + } + group.add(mesh) + node.mesh = mesh + node.material = material + } + + /** + * Nearest ancestor that is on screen (or about to become on screen in + * this same render-set pass), used as the crossfade underlay beneath a + * newly appearing tile. + */ + const nearestVisibleAncestorKey = (node: TileLayerNode): string | null => { + let { z, x, y } = node.index + while (z > 0) { + z -= 1 + x = Math.floor(x / 2) + y = Math.floor(y / 2) + const ancestor = nodes.get(tileKey({ z, x, y })) + if (ancestor?.mesh && (ancestor.mesh.visible || ancestor.rendering)) { + return tileKey(ancestor.index) + } + } + return null + } + + /** + * Apply the pyramid's render set to the meshes: lazily create the mesh + * of a node that just became visible, toggle every mesh's visibility + * and start fade-ins. Newly visible nodes are classified before any + * visibility flips, so an ancestor replaced by its children still + * counts as on screen and becomes the crossfade underlay. Tiles that + * have been on screen before reappear instantly, like MapLibre's + * cached tiles. + */ + const applyRenderSet = () => { + pyramid.renderSet() + const appearing: TileLayerNode[] = [] + nodes.forEach((node) => { + if (node.rendering && (!node.mesh || !node.mesh.visible)) appearing.push(node) + }) + const now = performance.now() + appearing.forEach((node) => { + if (!node.mesh) buildNodeMesh(node) + if (!node.material) return + if (!canFade || node.shownOnce) { + node.shownOnce = true + node.material.opacity = opacityRef.current + return + } + node.fadeStart = now + node.material.opacity = 0 + const underlayKey = nearestVisibleAncestorKey(node) + if (underlayKey) { + node.underlayKey = underlayKey + underlayRefs.set(underlayKey, (underlayRefs.get(underlayKey) ?? 0) + 1) + } + fading.add(tileKey(node.index)) + }) + let anyVisible = false + nodes.forEach((node) => { + if (node.rendering && !node.mesh) buildNodeMesh(node) + if (node.mesh) { + node.mesh.visible = + node.rendering || underlayRefs.has(tileKey(node.index)) + } + if (node.rendering) anyVisible = true + if (!node.rendering) endFade(node) + }) + hasVisibleTiles = anyVisible + } + + const resetQueue = () => { + queueBuckets.clear() + nodes.forEach((node) => { + node.queued = false + }) + } + + const enqueue = (node: TileLayerNode) => { + if (node.queued || node.state === TileState.Loading || node.state === TileState.Loaded) { + return + } + node.queued = true + const bucketLevel = node.index.z + let bucket = queueBuckets.get(bucketLevel) + if (!bucket) { + bucket = new Set() + queueBuckets.set(bucketLevel, bucket) + } + bucket.add(tileKey(node.index)) + } + + /** Squared distance from a node centre to the camera, used to load nearer + * tiles first within one zoom level. */ + const nodeDistanceSquared = ( + node: TileLayerNode, + camera: THREE.PerspectiveCamera + ): number => { + const bounds = node.bounds + if (!bounds) return Infinity + const center = boundsCenter(bounds) + const dx = center.x - camera.position.x + const dz = center.z - camera.position.z + return dx * dx + dz * dz + } + + const nextQueuedKey = (camera: THREE.PerspectiveCamera | null): string | null => { + const levels = Array.from(queueBuckets.keys()).sort((a, b) => a - b) + for (const level of levels) { + const bucket = queueBuckets.get(level) + if (!bucket || bucket.size === 0) continue + if (!camera) { + const anyKey = bucket.values().next().value as string | undefined + if (anyKey) { + bucket.delete(anyKey) + if (bucket.size === 0) queueBuckets.delete(level) + return anyKey + } + return null + } + let nearestKey: string | null = null + let nearestDistance = Infinity + bucket.forEach((key) => { + const node = nodes.get(key) + if (!node) { + bucket.delete(key) + return + } + const distance = nodeDistanceSquared(node, camera) + if (distance < nearestDistance) { + nearestDistance = distance + nearestKey = key + } + }) + if (nearestKey) { + bucket.delete(nearestKey) + if (bucket.size === 0) queueBuckets.delete(level) + return nearestKey + } + } + return null + } + + const pump = () => { + const cameraForQueue = + contextCamera instanceof THREE.PerspectiveCamera ? contextCamera : null + while (activeLoads < MAX_CONCURRENT_LOADS) { + const key = nextQueuedKey(cameraForQueue) + if (!key) break + const node = nodes.get(key) + if (!node) continue + node.queued = false + startLoad(node) + } + } + + /** Release an unused load result: Texture dispose / ImageBitmap close. */ + const discardLoadResult = (result: THREE.Texture | ImageBitmap): void => { + if (result instanceof THREE.Texture) result.dispose() + else result.close() + } + + /** + * Shared texture setup. Raster tiles stay near 1:1 screen size (SSE + * 256), so a mip chain would only cost upload time and GPU memory; + * plain linear filtering is what MapLibre uses for raster tiles. + */ + const configureTileTexture = (texture: THREE.Texture): void => { + texture.colorSpace = THREE.SRGBColorSpace + texture.generateMipmaps = false + texture.minFilter = THREE.LinearFilter + texture.magFilter = THREE.LinearFilter + } + + /** + * Normalise a load result into a tile texture. ImageBitmapLoader + * resolves the worker-decoded bitmap, already flipped by its + * imageOrientation option, so upload-time flipping stays off on both + * renderer backends; TextureLoader resolves a ready-made Texture. + */ + const prepareTileTexture = (result: THREE.Texture | ImageBitmap): THREE.Texture => { + if (result instanceof THREE.Texture) { + configureTileTexture(result) + return result + } + const texture = new THREE.Texture(result) + texture.flipY = false + texture.needsUpdate = true + configureTileTexture(texture) + return texture + } + + const startLoad = (node: TileLayerNode) => { + node.state = TileState.Loading + activeLoads += 1 + // Token for this attempt: when a timed-out request's callbacks finally + // arrive, a retry may already be in flight for the same node, and the + // stale callbacks must not fail the fresh attempt, drop its texture or + // corrupt the concurrency counter. + const token = ++loadSerial + node.loadToken = token + const requestUrl = tileUrl(url, node.index, subdomains) + const handleFailure = () => { + if (cancelled) { + activeLoads -= 1 + return + } + onTileErrorRef.current?.(node.index, requestUrl) + activeLoads -= 1 + if (node.attempts < MAX_LOAD_ATTEMPTS) { + node.attempts += 1 + node.state = TileState.Failed + enqueue(node) + } else { + node.state = TileState.Failed + node.queued = false + } + pump() + } + let timeoutId = 0 + const settle = () => { + window.clearTimeout(timeoutId) + } + timeoutId = window.setTimeout(() => { + // A download wedged past the ceiling fails fast into the retry + // queue instead of blocking its node for the browser's TCP timeout. + if (cancelled || node.loadToken !== token) return + handleFailure() + }, TILE_LOAD_TIMEOUT_MS) + loader.load( + requestUrl, + (result: THREE.Texture | ImageBitmap) => { + settle() + if (cancelled) { + discardLoadResult(result) + activeLoads -= 1 + return + } + if (node.loadToken !== token) { + // Stale attempt (already timed out and counted): drop the image. + discardLoadResult(result) + return + } + node.texture = prepareTileTexture(result) + node.state = TileState.Loaded + activeLoads -= 1 + applyRenderSet() + pump() + }, + undefined, + () => { + settle() + if (cancelled) { + activeLoads -= 1 + return + } + if (node.loadToken !== token) { + // Stale attempt; its load slot was already released by timeout. + return + } + handleFailure() + } + ) + } + + // Tile screen-space error in CSS pixels. A tile of on-ground size g seen + // from distance d projects to roughly g * viewportHeight / + // (2 d tan(fov/2)) pixels; it needs refining once that exceeds the + // threshold. The vertical gap to the plane counts toward d so a + // top-down view uses its height, not a near-zero distance. + const screenSpaceError = ( + node: TileLayerNode, + camera: THREE.PerspectiveCamera + ): number => { + const bounds = computeBounds(node, geo, effectiveDatum) + const center = boundsCenter(bounds) + const groundWidth = Math.max(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) + const dx = center.x - camera.position.x + const dz = center.z - camera.position.z + const dy = Math.max(camera.position.y - y, 0) + const distance = Math.max(Math.hypot(dx, dz, dy), camera.near) + const canvasHeight = sceneContext?.renderer?.domElement?.clientHeight + const viewportHeight = + canvasHeight && canvasHeight > 0 ? canvasHeight : window.innerHeight || 1 + const fovFactor = 2 * Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2) + return (groundWidth * viewportHeight) / (distance * fovFactor) + } + + const updateDynamic = () => { + const camera = contextCamera as THREE.PerspectiveCamera + camera.updateMatrixWorld() + const samples = sampleVisibleGround(camera, y) + const rect: GroundRect = { + minX: Math.min(...samples.map((sample) => sample.x)), + maxX: Math.max(...samples.map((sample) => sample.x)), + minZ: Math.min(...samples.map((sample) => sample.z)), + maxZ: Math.max(...samples.map((sample) => sample.z)) + } + + resetQueue() + + // The padding margin is derived once from the target zoom's centre + // leaf tile, so every level of the pyramid shares one ground-distance + // ring around the visible rectangle. + const footprintCenter = new THREE.Vector3( + (rect.minX + rect.maxX) / 2, + y, + (rect.minZ + rect.maxZ) / 2 + ) + const centerGeo = geo.toGeo(footprintCenter, effectiveDatum) + const leafCenterTile = lngLatToTile(centerGeo.lng, centerGeo.lat, zoom) + const leafBounds = computeBounds( + pyramid.getOrCreate(leafCenterTile), + geo, + effectiveDatum + ) + const leafGroundSize = Math.max( + leafBounds.maxX - leafBounds.minX, + leafBounds.maxZ - leafBounds.minZ, + 1e-6 + ) + const coverageMargin = leafGroundSize * Math.max(padding, 0) + + pyramid.selectIdeal({ + rect, + boundsOf: (index) => computeBounds(pyramid.getOrCreate(index), geo, effectiveDatum), + margin: coverageMargin, + maxZoom: zoom, + shouldRefine: (index) => + screenSpaceError(pyramid.getOrCreate(index), camera) >= SSE_THRESHOLD_PX, + maxIdealNodes: MAX_IDEAL_NODES + }) + + // Queue every ideal node without usable imagery: coarse ancestors and + // fine leaves are requested together, so the layer streams from the + // root covers straight to full detail instead of blocking level by + // level. The queue drains coarse buckets first. + pyramid.pendingIdeal().forEach((node) => { + enqueue(node) + }) + + applyRenderSet() + pyramid.evictStale(maxTiles, CACHE_FACTOR).forEach((node) => { + disposeNodeResources(node) + }) + pump() + + // Re-check after the camera has crossed roughly half an ideal tile so + // panning streams new tiles before the covered edge reaches the border. + let smallestGroundSize = Infinity + nodes.forEach((node) => { + if (node.rendering && node.bounds) { + const size = Math.max( + node.bounds.maxX - node.bounds.minX, + node.bounds.maxZ - node.bounds.minZ + ) + smallestGroundSize = Math.min(smallestGroundSize, size) + } + }) + currentThreshold = Number.isFinite(smallestGroundSize) + ? Math.max(1, smallestGroundSize / 2) + : Infinity + } + + // Static coverage: the bbox of the points is filled once at the given + // zoom. Traversal still runs the pyramid so the parent-cover rule hides + // the initial download behind coarse ancestors instead of black holes. + const updateStatic = () => { + if (!points || points.length === 0) return + + const west = Math.min(...points.map((point) => point.lng)) + const east = Math.max(...points.map((point) => point.lng)) + const south = Math.min(...points.map((point) => point.lat)) + const north = Math.max(...points.map((point) => point.lat)) + const coverageRect: GroundRect = { + minX: Infinity, + maxX: -Infinity, + minZ: Infinity, + maxZ: -Infinity + } + ;[ + { lng: west, lat: north }, + { lng: east, lat: north }, + { lng: east, lat: south }, + { lng: west, lat: south } + ].forEach((corner) => { + const local = geo.toLocal(corner, effectiveDatum) + coverageRect.minX = Math.min(coverageRect.minX, local.x) + coverageRect.maxX = Math.max(coverageRect.maxX, local.x) + coverageRect.minZ = Math.min(coverageRect.minZ, local.z) + coverageRect.maxZ = Math.max(coverageRect.maxZ, local.z) + }) + + resetQueue() + pyramid.selectIdeal({ + rect: coverageRect, + boundsOf: (index) => computeBounds(pyramid.getOrCreate(index), geo, effectiveDatum), + maxZoom: zoom + }) + pyramid.pendingIdeal().forEach((node) => { + enqueue(node) + }) + + applyRenderSet() + pyramid.evictStale(maxTiles, CACHE_FACTOR).forEach((node) => { + disposeNodeResources(node) + }) + pump() + } + + let currentThreshold = Infinity + let lastFillAt = 0 + const lastCheckPosition = new THREE.Vector3() + const lastCheckTarget = new THREE.Vector3() + const lastCheckQuaternion = new THREE.Quaternion() + let lastOrbitDistance = -1 + + const runFill = () => { + trailingTimer = 0 + lastFillAt = performance.now() + if (dynamic) updateDynamic() + else updateStatic() + } + + const scheduleFill = () => { + // Leading edge of a gesture fills immediately; subsequent traversals + // while the camera keeps moving are coalesced into a trailing timer, + // which bounds queue churn and keeps zooming smooth at any altitude. + const elapsed = performance.now() - lastFillAt + if (elapsed >= FILL_THROTTLE_MS) { + runFill() + return + } + if (trailingTimer) return + trailingTimer = window.setTimeout(runFill, FILL_THROTTLE_MS - elapsed) + } + + if (dynamic) { + const camera = contextCamera as THREE.PerspectiveCamera + const controls = sceneContext?.sceneComponents?.controls + if (controls) { + // Keep the camera above the horizon so the edge-on ground plane + // cannot stretch tile imagery across the sky, cap the zoom-out + // distance below the camera far plane (past it every ground quad is + // GPU-clipped and the whole map turns into empty background), and + // floor the zoom-in distance (Cesium's minimumZoomDistance): dolling + // into ground-level content puts the camera inside markers and slides + // the fog band over the whole frame. In every case a stricter + // existing limit is left untouched; the original values are restored + // on unmount. + const previousMaxPolarAngle = controls.maxPolarAngle + const previousMaxDistance = controls.maxDistance + const previousMinDistance = controls.minDistance + const farDistanceCap = camera.far > 0 ? camera.far * 0.9 : Infinity + const minDistanceFloor = Number.isFinite(farDistanceCap) + ? Math.max(farDistanceCap * MIN_DISTANCE_FACTOR, 1) + : 0 + let controlsTouched = false + if (maxPolarAngle < previousMaxPolarAngle) { + controls.maxPolarAngle = maxPolarAngle + controlsTouched = true + } + if (farDistanceCap < previousMaxDistance) { + controls.maxDistance = farDistanceCap + controlsTouched = true + } + if (minDistanceFloor > previousMinDistance) { + controls.minDistance = minDistanceFloor + controlsTouched = true + } + if (controlsTouched) { + controls.update() + restoreControls = () => { + controls.maxPolarAngle = previousMaxPolarAngle + controls.maxDistance = previousMaxDistance + controls.minDistance = previousMinDistance + } + } + } + + const checkFrame = () => { + if (cancelled) return + // The orbit distance feeds both the atmosphere below (edge-anchored + // fog, far-plane fit) and the zoom trigger, so compute it once per + // frame, before anything else uses it. + const orbitDistance = controls ? camera.position.distanceTo(controls.target) : -1 + // Fog planes follow the camera every frame (cheap parameter writes), + // independently of whether a cover traversal is scheduled. + applyAtmosphere(camera, orbitDistance) + // An orientation delta triggers a retile independently of movement: + // tilting rotates about the orbit target with little translation, so + // a displacement-only threshold could leave the selection frozen at + // the final angle. + const rotated = camera.quaternion.angleTo(lastCheckQuaternion) > MAX_ORIENTATION_DELTA + // Before the first successful fill the threshold is Infinity, so the + // first check always runs even if the camera has not moved. + const moved = + currentThreshold === Infinity || + camera.position.distanceTo(lastCheckPosition) > currentThreshold || + (controls ? controls.target.distanceTo(lastCheckTarget) > currentThreshold : false) + // Zooming changes every tile's screen-space error without moving the + // camera across the pan threshold (half a finest tile's ground size, + // hundreds of metres), so a dolly would trigger no re-selection for + // dozens of wheel steps and no tile request would ever fire. Mature + // engines re-evaluate the pyramid on every zoom step; track the + // orbit distance separately and re-select after a few percent of it. + const zoomed = + orbitDistance > 0 && + lastOrbitDistance > 0 && + Math.abs(orbitDistance - lastOrbitDistance) > lastOrbitDistance * 0.02 + if (!moved && !rotated && !zoomed) return + lastCheckPosition.copy(camera.position) + if (controls?.target) lastCheckTarget.copy(controls.target) + lastCheckQuaternion.copy(camera.quaternion) + lastOrbitDistance = orbitDistance + scheduleFill() + } + + runFill() + const initialOrbitDistance = controls ? camera.position.distanceTo(controls.target) : -1 + applyAtmosphere(camera, initialOrbitDistance) + lastCheckPosition.copy(camera.position) + if (controls?.target) lastCheckTarget.copy(controls.target) + lastOrbitDistance = initialOrbitDistance + // addAfterFrame is required rather than addBeforeFrame: the latter + // switches the Scene into a layer-compositing render path that skips + // the per-frame clear, which on WebGPU leaves trails behind moving + // markers and tiles during an orbit/drag gesture. + if (sceneContext?.addAfterFrame) { + disposeAfterFrame = sceneContext.addAfterFrame(checkFrame) + } else { + intervalCounters.id = window.setInterval(checkFrame, 200) + } + } else { + runFill() + const staticCamera = + sceneContext?.sceneComponents?.camera instanceof THREE.PerspectiveCamera + ? sceneContext.sceneComponents.camera + : null + if (staticCamera && sceneContext?.addAfterFrame) { + applyAtmosphere(staticCamera) + disposeAtmosphereFrame = sceneContext.addAfterFrame(() => { + if (!cancelled) applyAtmosphere(staticCamera) + }) + } else { + applyAtmosphere(null) + } + } + } + + init() + + return () => { + cancelled = true + window.clearTimeout(intervalCounters.id) + window.clearTimeout(trailingTimer) + window.clearInterval(intervalCounters.id) + disposeAfterFrame?.() + disposeFadeFrame?.() + restoreControls?.() + cleanupAtmosphere?.() + pyramid.clear().forEach((node) => { + disposeNodeResources(node) + }) + const group = groupRef.current + if (group) { + group.removeFromParent() + groupRef.current = null + } + loaderRef.current = null + pyramidRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + propScene, + sceneContext?.scene, + sceneContext?.geo, + sceneContext?.sceneComponents, + sceneContext?.addAfterFrame, + url, + points, + zoom, + followCamera, + effectiveDatum, + padding, + subdomains, + y, + maxTiles, + maxPolarAngle + ]) + + return null +} + +export default TileLayer diff --git a/src/context/SceneContext.tsx b/src/context/SceneContext.tsx index 7aff394..bb0afca 100644 --- a/src/context/SceneContext.tsx +++ b/src/context/SceneContext.tsx @@ -2,6 +2,7 @@ import { createContext, useContext } from 'react' import * as THREE from 'three' import type { Renderer } from 'three/webgpu' import type { OrbitControls } from 'three-stdlib' +import type { GeoReference } from '../crs/GeoReference' /** * Unified renderer type accepted by R3L utilities and components. Covers both @@ -18,11 +19,14 @@ export type R3LRenderer = Renderer & { } export type SceneComponents = { - camera: THREE.Camera + // Scene always creates (or accepts) a PerspectiveCamera, so consumers can + // read perspective-only fields (fov/near/far) without an instanceof guard. + camera: THREE.PerspectiveCamera light: THREE.Object3D axesHelper: THREE.AxesHelper | undefined gridHelper: THREE.GridHelper | undefined controls: OrbitControls + geo: GeoReference } export type SceneSlotProps = { @@ -30,6 +34,7 @@ export type SceneSlotProps = { renderer?: R3LRenderer scene?: THREE.Scene sceneComponents?: SceneComponents + geo?: GeoReference picker?: import('../utils/Picker').default setFrame?: (callback: CallbackFrame) => void addBeforeFrame?: (callback: CallbackFrame) => () => void diff --git a/src/crs/Datum.ts b/src/crs/Datum.ts new file mode 100644 index 0000000..f030c33 --- /dev/null +++ b/src/crs/Datum.ts @@ -0,0 +1,105 @@ +import { DEG2RAD, GCJ_A, GCJ_EE, CHINA_BOUNDS } from './constants' +import type { DatumType, GeoPoint } from './types' + +const X_PI = (Math.PI * 3000.0) / 180.0 + +function isInChina(lng: number, lat: number): boolean { + return ( + lng >= CHINA_BOUNDS.lngMin && + lng <= CHINA_BOUNDS.lngMax && + lat >= CHINA_BOUNDS.latMin && + lat <= CHINA_BOUNDS.latMax + ) +} + +function transformLat(x: number, y: number): number { + let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)) + ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0 + ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin((y / 3.0) * Math.PI)) * 2.0 / 3.0 + ret += (160.0 * Math.sin((y / 12.0) * Math.PI) + 320 * Math.sin((y * Math.PI) / 30.0)) * 2.0 / 3.0 + return ret +} + +function transformLng(x: number, y: number): number { + let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)) + ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0 + ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin((x / 3.0) * Math.PI)) * 2.0 / 3.0 + ret += (150.0 * Math.sin((x / 12.0) * Math.PI) + 300.0 * Math.sin((x / 30.0) * Math.PI)) * 2.0 / 3.0 + return ret +} + +/** WGS84 -> GCJ02 ("Mars"). Coordinates outside mainland China are returned unchanged. */ +export function wgs84ToGcj02(p: GeoPoint): GeoPoint { + const { lng, lat, alt } = p + if (!isInChina(lng, lat)) return { lng, lat, alt } + + // Standard GCJ02 deltas are polynomials over the offset from the GCJ + // reference meridian/parallel (105E / 35N). + const dX = lng - 105.0 + const dY = lat - 35.0 + let dLat = transformLat(dX, dY) + let dLng = transformLng(dX, dY) + const radLat = lat * DEG2RAD + let magic = Math.sin(radLat) + magic = 1.0 - GCJ_EE * magic * magic + const sqrtMagic = Math.sqrt(magic) + // Polynomial deltas are in metres; convert to radians (then to degrees). + dLat = dLat / (((GCJ_A * (1.0 - GCJ_EE)) / (magic * sqrtMagic)) * DEG2RAD) + dLng = dLng / ((GCJ_A / sqrtMagic) * Math.cos(radLat) * DEG2RAD) + return { lng: lng + dLng, lat: lat + dLat, alt } +} + +/** GCJ02 -> WGS84 via fixed-point iteration (sub-metre accuracy after a few steps). */ +export function gcj02ToWgs84(p: GeoPoint): GeoPoint { + const { lng, lat, alt } = p + if (!isInChina(lng, lat)) return { lng, lat, alt } + + let wLng = lng + let wLat = lat + for (let i = 0; i < 6; i++) { + const shifted = wgs84ToGcj02({ lng: wLng, lat: wLat }) + wLng += lng - shifted.lng + wLat += lat - shifted.lat + } + return { lng: wLng, lat: wLat, alt } +} + +/** GCJ02 -> BD09 (Baidu). */ +export function gcj02ToBd09(p: GeoPoint): GeoPoint { + const { lng, lat, alt } = p + const x = lng + const y = lat + const z = Math.sqrt(x * x + y * y) + 0.00002 * Math.sin(y * X_PI) + const theta = Math.atan2(y, x) + 0.000003 * Math.cos(x * X_PI) + return { lng: z * Math.cos(theta) + 0.0065, lat: z * Math.sin(theta) + 0.006, alt } +} + +/** BD09 (Baidu) -> GCJ02. */ +export function bd09ToGcj02(p: GeoPoint): GeoPoint { + const { lng, lat, alt } = p + const x = lng - 0.0065 + const y = lat - 0.006 + const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI) + const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI) + return { lng: z * Math.cos(theta), lat: z * Math.sin(theta), alt } +} + +/** Normalize any supported datum to WGS84. */ +export function toWGS84(p: GeoPoint, datum: DatumType): GeoPoint { + if (datum === 'WGS84') return p + if (datum === 'GCJ02') return gcj02ToWgs84(p) + return gcj02ToWgs84(bd09ToGcj02(p)) +} + +/** Express a WGS84 point in the requested datum. */ +export function fromWGS84(p: GeoPoint, datum: DatumType): GeoPoint { + if (datum === 'WGS84') return p + if (datum === 'GCJ02') return wgs84ToGcj02(p) + return gcj02ToBd09(wgs84ToGcj02(p)) +} + +/** Convert a point between any two supported datums. */ +export function convertDatum(p: GeoPoint, from: DatumType, to: DatumType): GeoPoint { + if (from === to) return p + return fromWGS84(toWGS84(p, from), to) +} diff --git a/src/crs/GeoJson.ts b/src/crs/GeoJson.ts new file mode 100644 index 0000000..3be0d49 --- /dev/null +++ b/src/crs/GeoJson.ts @@ -0,0 +1,133 @@ +import type { GeoPoint } from './types' + +export type GeoJsonPosition = number[] + +export interface GeoJsonPoint { + type: 'Point' + coordinates: GeoJsonPosition +} + +export interface GeoJsonMultiPoint { + type: 'MultiPoint' + coordinates: GeoJsonPosition[] +} + +export interface GeoJsonLineString { + type: 'LineString' + coordinates: GeoJsonPosition[] +} + +export interface GeoJsonMultiLineString { + type: 'MultiLineString' + coordinates: GeoJsonPosition[][] +} + +export interface GeoJsonPolygon { + type: 'Polygon' + coordinates: GeoJsonPosition[][] +} + +export interface GeoJsonMultiPolygon { + type: 'MultiPolygon' + coordinates: GeoJsonPosition[][][] +} + +export type GeoJsonGeometry = + | GeoJsonPoint + | GeoJsonMultiPoint + | GeoJsonLineString + | GeoJsonMultiLineString + | GeoJsonPolygon + | GeoJsonMultiPolygon + +export interface GeoJsonFeature { + type: 'Feature' + geometry: GeoJsonGeometry | null + properties?: Record | null +} + +export interface GeoJsonFeatureCollection { + type: 'FeatureCollection' + features: GeoJsonFeature[] +} + +export type GeoJsonData = GeoJsonFeatureCollection | GeoJsonFeature | GeoJsonGeometry + +/** A single polygon ring: the outer ring followed by zero or more holes. */ +export interface GeoJsonRingSet { + outer: GeoPoint[] + holes: GeoPoint[][] +} + +export interface GeoJsonGeometryData { + points: GeoPoint[] + lines: GeoPoint[][] + polygons: GeoJsonRingSet[] +} + +function toGeoPoint(position: GeoJsonPosition): GeoPoint { + return { lng: position[0], lat: position[1], alt: position[2] ?? 0 } +} + +/** Collect every geometry inside a FeatureCollection/Feature/raw geometry. */ +export function collectGeometries(data: GeoJsonData): GeoJsonGeometry[] { + if (data.type === 'FeatureCollection') { + return data.features + .map((feature) => feature.geometry) + .filter((geometry): geometry is GeoJsonGeometry => geometry !== null) + } + if (data.type === 'Feature') { + return data.geometry ? [data.geometry] : [] + } + return [data] +} + +/** + * Flatten arbitrary GeoJSON into de-duplicated geometry buckets. Positions are + * kept as geographic points; projecting them to local metres is the renderer's + * job (see GeoJsonLayer), which keeps this helper pure and unit-testable. + */ +export function flattenGeoJson(data: GeoJsonData): GeoJsonGeometryData { + const result: GeoJsonGeometryData = { points: [], lines: [], polygons: [] } + + const addLine = (positions: GeoJsonPosition[]) => { + if (positions.length > 0) result.lines.push(positions.map(toGeoPoint)) + } + + for (const geometry of collectGeometries(data)) { + switch (geometry.type) { + case 'Point': + result.points.push(toGeoPoint(geometry.coordinates)) + break + case 'MultiPoint': + geometry.coordinates.forEach((position) => result.points.push(toGeoPoint(position))) + break + case 'LineString': + addLine(geometry.coordinates) + break + case 'MultiLineString': + geometry.coordinates.forEach(addLine) + break + case 'Polygon': + if (geometry.coordinates.length > 0) { + result.polygons.push({ + outer: geometry.coordinates[0].map(toGeoPoint), + holes: geometry.coordinates.slice(1).map((ring) => ring.map(toGeoPoint)) + }) + } + break + case 'MultiPolygon': + geometry.coordinates.forEach((polygon) => { + if (polygon.length > 0) { + result.polygons.push({ + outer: polygon[0].map(toGeoPoint), + holes: polygon.slice(1).map((ring) => ring.map(toGeoPoint)) + }) + } + }) + break + } + } + + return result +} diff --git a/src/crs/GeoReference.ts b/src/crs/GeoReference.ts new file mode 100644 index 0000000..499df81 --- /dev/null +++ b/src/crs/GeoReference.ts @@ -0,0 +1,139 @@ +import * as THREE from 'three' +import { DEFAULT_AXES, DEFAULT_ORIGIN } from './constants' +import { createLocalPlane } from './LocalPlane' +import { fromWGS84, toWGS84 } from './Datum' +import type { + AxesMapping, + CRS, + CrsCode, + DatumType, + GeoPoint, + GeoReferenceOptions, + LocalPlane +} from './types' + +interface AxisSpec { + axis: 0 | 1 | 2 + sign: 1 | -1 +} + +function parseAxis(spec: string): AxisSpec { + const negative = spec.trim().startsWith('-') + const name = spec.trim().replace(/^[+-]/, '') + if (name !== 'x' && name !== 'y' && name !== 'z') { + throw new Error(`GeoReference: invalid axis "${spec}", expected one of x/y/z with optional sign`) + } + return { axis: (name.charCodeAt(0) - 120) as 0 | 1 | 2, sign: negative ? -1 : 1 } +} + +function validateAxes(axes: AxesMapping): void { + const used = [axes.east, axes.north, axes.up].map((a) => a.replace(/^[+-]/, '')) + if (new Set(used).size !== 3) { + throw new Error('GeoReference: axes mapping must map east/north/up to three distinct axes') + } +} + +/** + * Binds a CRS, datum and axis convention to a fixed local origin and converts + * geographic coordinates to/from the small metre-space coordinates placed in + * the three.js scene. Keeping a local origin is what prevents float32 jitter at + * city scale (absolute Web Mercator values are ~1.3e7 metres). + */ +export class GeoReference { + readonly origin: GeoPoint + readonly crsCode: CrsCode + readonly datum: DatumType + readonly axes: AxesMapping + + private readonly plane: LocalPlane + private readonly east: AxisSpec + private readonly north: AxisSpec + private readonly up: AxisSpec + private readonly basisMatrix: THREE.Matrix4 + private readonly enuScratch: THREE.Vector3 + + constructor(origin: GeoPoint = DEFAULT_ORIGIN, options: GeoReferenceOptions = {}) { + const crsOption = options.crs ?? 'EPSG:4326' + this.crsCode = typeof crsOption === 'string' ? crsOption : (crsOption as CRS).code + this.datum = options.datum ?? 'WGS84' + this.axes = options.axes ?? DEFAULT_AXES + validateAxes(this.axes) + + this.origin = { lng: origin.lng, lat: origin.lat, alt: origin.alt ?? 0 } + this.plane = createLocalPlane(this.crsCode, this.origin) + + this.east = parseAxis(this.axes.east) + this.north = parseAxis(this.axes.north) + this.up = parseAxis(this.axes.up) + + this.basisMatrix = new THREE.Matrix4().makeBasis( + this.axisVector(this.east), + this.axisVector(this.north), + this.axisVector(this.up) + ) + this.enuScratch = new THREE.Vector3() + } + + private axisVector(spec: AxisSpec): THREE.Vector3 { + const v = new THREE.Vector3() + v.setComponent(spec.axis, spec.sign) + return v + } + + /** Convert a geographic point to local three.js metres relative to the origin. */ + toLocal(p: GeoPoint, datum: DatumType = this.datum, target?: THREE.Vector3): THREE.Vector3 { + const wgs = datum === 'WGS84' ? p : toWGS84(p, datum) + const enu = this.plane.toENU(wgs, this.enuScratch) + + const out = target ?? new THREE.Vector3() + out.set(0, 0, 0) + out.addScaledVector(this.axisVector(this.east), enu.x) + out.addScaledVector(this.axisVector(this.north), enu.y) + out.addScaledVector(this.axisVector(this.up), enu.z) + return out + } + + /** Convert local three.js metres back to a geographic point. */ + toGeo(v: THREE.Vector3, datum: DatumType = this.datum, target?: GeoPoint): GeoPoint { + const e = this.east.sign * v.getComponent(this.east.axis) + const n = this.north.sign * v.getComponent(this.north.axis) + const u = this.up.sign * v.getComponent(this.up.axis) + const enu = this.enuScratch.set(e, n, u) + const wgs = this.plane.fromENU(enu) + + if (target) { + Object.assign(target, datum === 'WGS84' ? wgs : fromWGS84(wgs, datum)) + return target + } + return datum === 'WGS84' ? wgs : fromWGS84(wgs, datum) + } + + /** + * Pure ENU -> three.js basis transform (no translation, the origin maps to + * (0,0,0)). Attach it to a Group whose children are authored in ENU space. + */ + get matrix(): THREE.Matrix4 { + return this.basisMatrix + } + + /** Place an existing object at a geographic position. */ + setPosition(object: THREE.Object3D, p: GeoPoint, datum: DatumType = this.datum): THREE.Object3D { + this.toLocal(p, datum, object.position) + return object + } + + /** Great-circle-free planar distance between two geographic points, in metres. */ + distance(a: GeoPoint, b: GeoPoint, datum: DatumType = this.datum): number { + const va = this.toLocal(a, datum) + const vb = this.toLocal(b, datum) + return va.distanceTo(vb) + } +} + +/** Convenience factory mirroring the functional style of the other utilities. */ +export function createGeoReference( + origin?: GeoPoint, + options?: GeoReferenceOptions +): GeoReference { + return new GeoReference(origin, options) +} diff --git a/src/crs/LocalPlane.ts b/src/crs/LocalPlane.ts new file mode 100644 index 0000000..f478015 --- /dev/null +++ b/src/crs/LocalPlane.ts @@ -0,0 +1,73 @@ +import * as THREE from 'three' +import { WGS84_A, DEG2RAD, MERCATOR_MAX_LAT } from './constants' +import { mercatorProject, mercatorUnproject } from './Mercator' +import type { CrsCode, GeoPoint, LocalPlane } from './types' + +function clampLat(lat: number): number { + return Math.min(MERCATOR_MAX_LAT, Math.max(-MERCATOR_MAX_LAT, lat)) +} + +/** + * Local equirectangular (plate carree) tangent plane for EPSG:4326. + * East/north offsets are true metres using the cosine of the origin latitude, + * which is accurate to within a few centimetres for city-scale (<20 km) data. + */ +class EquirectPlane implements LocalPlane { + readonly origin: GeoPoint + private readonly originLatRad: number + private readonly metersPerDegLat: number + private readonly metersPerDegLng: number + + constructor(origin: GeoPoint) { + this.origin = { lng: origin.lng, lat: clampLat(origin.lat), alt: origin.alt ?? 0 } + this.originLatRad = this.origin.lat * DEG2RAD + this.metersPerDegLat = (Math.PI * WGS84_A) / 180 + this.metersPerDegLng = this.metersPerDegLat * Math.cos(this.originLatRad) + } + + toENU(p: GeoPoint, target?: THREE.Vector3): THREE.Vector3 { + const east = (p.lng - this.origin.lng) * this.metersPerDegLng + const north = (p.lat - this.origin.lat) * this.metersPerDegLat + const up = (p.alt ?? 0) - (this.origin.alt ?? 0) + return (target ?? new THREE.Vector3()).set(east, north, up) + } + + fromENU(enu: THREE.Vector3, target?: GeoPoint): GeoPoint { + const lng = this.origin.lng + enu.x / this.metersPerDegLng + const lat = this.origin.lat + enu.y / this.metersPerDegLat + const alt = (this.origin.alt ?? 0) + enu.z + return target ? Object.assign(target, { lng, lat, alt }) : { lng, lat, alt } + } +} + +/** + * Local Web Mercator plane for EPSG:3857. Points are projected to Mercator + * metres and the origin's Mercator position is subtracted. The plane preserves + * Mercator shapes exactly (ideal for overlaying XYZ raster tiles later) but its + * vertical scale is stretched by 1/cos(lat) away from the equator. + */ +class MercatorPlane implements LocalPlane { + readonly origin: GeoPoint + private readonly originMeters: THREE.Vector3 + + constructor(origin: GeoPoint) { + this.origin = { lng: origin.lng, lat: clampLat(origin.lat), alt: origin.alt ?? 0 } + this.originMeters = mercatorProject(this.origin) + } + + toENU(p: GeoPoint, target?: THREE.Vector3): THREE.Vector3 { + const m = mercatorProject(p, target) + m.sub(this.originMeters) + return m + } + + fromENU(enu: THREE.Vector3, target?: GeoPoint): GeoPoint { + const abs = enu.clone().add(this.originMeters) + return mercatorUnproject(abs, target) + } +} + +/** Create the local tangent plane for a CRS code at the given origin. */ +export function createLocalPlane(crs: CrsCode, origin: GeoPoint): LocalPlane { + return crs === 'EPSG:3857' ? new MercatorPlane(origin) : new EquirectPlane(origin) +} diff --git a/src/crs/Mercator.ts b/src/crs/Mercator.ts new file mode 100644 index 0000000..2d63228 --- /dev/null +++ b/src/crs/Mercator.ts @@ -0,0 +1,38 @@ +import * as THREE from 'three' +import { WGS84_A, DEG2RAD, RAD2DEG, MERCATOR_MAX_LAT } from './constants' +import type { CRS, CrsCode, GeoPoint } from './types' + +function clampLat(lat: number): number { + return Math.min(MERCATOR_MAX_LAT, Math.max(-MERCATOR_MAX_LAT, lat)) +} + +/** Spherical (Web) Mercator projection, EPSG:3857. Output is metres in float64. */ +class MercatorCRS implements CRS { + readonly code: CrsCode = 'EPSG:3857' + + project(p: GeoPoint, target?: THREE.Vector3): THREE.Vector3 { + const lat = clampLat(p.lat) + const x = WGS84_A * p.lng * DEG2RAD + const y = WGS84_A * Math.log(Math.tan(Math.PI / 4 + (lat * DEG2RAD) / 2)) + const z = p.alt ?? 0 + return (target ?? new THREE.Vector3()).set(x, y, z) + } + + unproject(v: THREE.Vector3, target?: GeoPoint): GeoPoint { + const lng = (v.x / WGS84_A) * RAD2DEG + const lat = (2 * Math.atan(Math.exp(v.y / WGS84_A)) - Math.PI / 2) * RAD2DEG + return target ? Object.assign(target, { lng, lat, alt: v.z }) : { lng, lat, alt: v.z } + } +} + +export const mercatorCRS: CRS = new MercatorCRS() + +/** Project a geographic point to EPSG:3857 metres (x = easting, y = northing, z = height). */ +export function mercatorProject(p: GeoPoint, target?: THREE.Vector3): THREE.Vector3 { + return mercatorCRS.project(p, target) +} + +/** Inverse of mercatorProject. */ +export function mercatorUnproject(v: THREE.Vector3, target?: GeoPoint): GeoPoint { + return mercatorCRS.unproject(v, target) +} diff --git a/src/crs/SceneGeo.ts b/src/crs/SceneGeo.ts new file mode 100644 index 0000000..dccf8f5 --- /dev/null +++ b/src/crs/SceneGeo.ts @@ -0,0 +1,137 @@ +import * as THREE from 'three' +import { GeoReference } from './GeoReference' +import type { DatumType, GeoPoint } from './types' + +/** + * A position already expressed in the local three.js metre frame. Both + * `THREE.Vector3` and a plain `{x, y, z}` object are accepted. + */ +export type SceneLocalPoint = THREE.Vector3 | { x: number; y: number; z?: number } + +/** + * Every position shape accepted by `scene.setPosition`. Because the scene + * already carries its {@link GeoReference}, a geographic position can be given + * as a plain `[lng, lat]` / `[lng, lat, alt]` tuple or a `{lng, lat, alt}` + * object; it is projected through the scene's reference. A `THREE.Vector3` / + * `{x, y, z}` is treated as local metres and used as-is. + */ +export type ScenePosition = GeoPoint | [number, number] | [number, number, number] | SceneLocalPoint + +/** + * Scene-level geographic positioning. + * + * A scene created by R3L's `` carries its {@link GeoReference} in + * `scene.userData.geo` (attached once while the scene is created), so callers + * never need to thread the reference around by hand. The same `setPosition` + * call positions a camera and a mesh because both are `THREE.Object3D`. + * + * When the scene has no bound reference (helpers installed without a geo, or a + * scene whose geo was cleared), geographic tuples/objects fall back to local + * coordinates: `lng/lat/alt` map straight to `x/y/z` in metres, so the call is + * always safe. + */ +declare module 'three' { + interface Scene { + /** The geographic reference bound to this scene, if any. */ + getGeo?: () => GeoReference | undefined + /** + * Place any object (camera, mesh, group) at a position. A `[lng, lat]` + * tuple or `{lng, lat}` point is projected through the scene's reference; + * a `THREE.Vector3` / `{x, y, z}` is used as local metres. Without a bound + * reference, geographic inputs map straight onto `x/y/z`. + */ + setPosition: ( + object: T, + position: ScenePosition, + datum?: DatumType + ) => T + } +} + +function readSceneGeo(scene: THREE.Scene): GeoReference | undefined { + const geo = scene.userData?.geo as unknown + return geo instanceof GeoReference ? geo : undefined +} + +function isGeoPosition( + position: ScenePosition +): position is GeoPoint | [number, number] | [number, number, number] { + if (Array.isArray(position)) return true + return ( + position !== null && + typeof position === 'object' && + typeof (position as GeoPoint).lng === 'number' && + typeof (position as GeoPoint).lat === 'number' + ) +} + +function toGeoPoint(position: GeoPoint | [number, number] | [number, number, number]): GeoPoint { + if (Array.isArray(position)) { + return { lng: position[0], lat: position[1], alt: position[2] } + } + return position +} + +function applyLocal(object: THREE.Object3D, position: SceneLocalPoint): void { + // Duck-type via isVector3 so vectors from the separate `three/webgpu` bundle + // (whose Vector3 has a different class identity) are still copied directly. + if ((position as THREE.Vector3).isVector3 === true) { + object.position.copy(position as THREE.Vector3) + } else { + const local = position as { x: number; y: number; z?: number } + object.position.set(local.x, local.y, local.z ?? 0) + } +} + +/** + * Install the `getGeo`/`setPosition` helpers on a scene instance and, when a + * {@link GeoReference} is supplied, bind it to `scene.userData.geo`. Called + * once by R3L while the scene is created; safe to call again to rebind or to + * clear the reference (pass `undefined`). Methods live on the instance rather + * than the prototype because `three` and `three/webgpu` are separate bundles + * with distinct Scene classes. + */ +export function bindSceneGeo(scene: THREE.Scene, geo?: GeoReference): THREE.Scene { + if (geo) { + scene.userData.geo = geo + } + scene.getGeo = () => readSceneGeo(scene) + scene.setPosition = ( + object: T, + position: ScenePosition, + datum?: DatumType + ): T => { + if (isGeoPosition(position)) { + const point = toGeoPoint(position) + const reference = readSceneGeo(scene) + if (reference) { + reference.setPosition(object, point, datum) + } else { + object.position.set(point.lng, point.lat, point.alt ?? 0) + } + } else { + applyLocal(object, position) + } + return object + } + return scene +} + +/** Read the {@link GeoReference} bound to a scene, or `undefined` when none. */ +export function getSceneGeo(scene: THREE.Scene): GeoReference | undefined { + return readSceneGeo(scene) +} + +/** + * Ensure the scene carries the `setPosition` helper. Scenes built by R3L's + * `` already have it; a user-supplied bare scene (e.g. handed to + * `GeoObject` via its `scene` prop) is bound here without a reference, so + * positioning falls back to treating coordinates as local metres. Safe to call + * repeatedly — a scene that already has the helper is returned untouched. + */ +export function ensureSceneGeo(scene: THREE.Scene): THREE.Scene { + if (typeof scene.setPosition !== 'function') { + bindSceneGeo(scene) + } + return scene +} diff --git a/src/crs/TilePyramid.ts b/src/crs/TilePyramid.ts new file mode 100644 index 0000000..28fab41 --- /dev/null +++ b/src/crs/TilePyramid.ts @@ -0,0 +1,267 @@ +import type { TileIndex } from './Tiles' + +/** + * Axis-aligned rectangle on the local ground plane, in metres. + */ +export interface GroundRect { + minX: number + maxX: number + minZ: number + maxZ: number +} + +/** + * Lifecycle of one tile node in the selection pyramid. + */ +export enum TileState { + Idle, + Loading, + Loaded, + Failed +} + +/** + * Mutable per-tile selection state. Concrete layers extend this interface with + * their own resources (mesh, texture, cached bounds); the pyramid only reads + * and writes the fields declared here. + */ +export interface TileNodeBase { + index: TileIndex + state: TileState + attempts: number + /** Stamp of the last selection traversal that included this node. */ + lastSeen: number + queued: boolean + rendering: boolean +} + +export interface SelectIdealOptions { + /** Ground rectangle that must stay covered. */ + rect: GroundRect + /** Local ground bounds of a tile index; typically cached on the node. */ + boundsOf: (index: TileIndex) => GroundRect + /** Extra coverage margin in metres around the rectangle. */ + margin?: number + /** Deepest zoom level the pyramid refines to. */ + maxZoom: number + /** + * Per-tile refinement test (e.g. screen-space error). A tile keeps + * subdividing while this returns true. + */ + shouldRefine?: (index: TileIndex) => boolean + /** Early stop after this many ideal nodes. */ + maxIdealNodes?: number +} + +export const tileKey = (index: TileIndex): string => `${index.z}_${index.x}_${index.y}` + +/** Address of one of a tile's four children (quadrant 0..3). */ +export function childIndex(index: TileIndex, quadrant: number): TileIndex { + return { + z: index.z + 1, + x: index.x * 2 + (quadrant % 2), + y: index.y * 2 + (quadrant >> 1) + } +} + +/** + * The standard XYZ scheme has exactly one zoom-0 tile covering the whole + * world; it roots every traversal. A second root would be an out-of-range + * world copy: tile bounds wrap its x back onto the same ground as the real + * tiles, so both trees would render overlapping quads at every zoom while + * their URLs (built from the raw out-of-range x) fetch blank placeholder + * imagery - the map gets painted over with empty tiles. + */ +export const ROOT_TILES: TileIndex[] = [{ z: 0, x: 0, y: 0 }] + +function overlaps(bounds: GroundRect, rect: GroundRect, margin: number): boolean { + return ( + bounds.maxX + margin >= rect.minX && + bounds.minX - margin <= rect.maxX && + bounds.maxZ + margin >= rect.minZ && + bounds.minZ - margin <= rect.maxZ + ) +} + +/** + * Quadtree tile selection with ancestor-cover refinement, the model used by + * Cesium/3D Tiles style engines. + * + * One traversal (`selectIdeal`) marks the ideal set: every tile whose ground + * footprint touches the coverage rectangle, refined while the refinement test + * holds. `renderSet` then picks what is actually displayed: an ideal tile is + * only replaced by its children once every child the ideal region needs is + * loaded (atomic refinement), and while an ideal tile is still downloading the + * nearest loaded ancestor - or any loaded descendant that arrived early - + * paints its footprint instead. The visible surface therefore never has holes + * once the roots have loaded, and never overlaps two levels of detail. + */ +export class TilePyramid { + readonly nodes = new Map() + private stamp = 0 + private readonly maxLoadAttempts: number + private readonly createNode: (index: TileIndex) => N + + constructor(options: { createNode?: (index: TileIndex) => N; maxLoadAttempts?: number } = {}) { + this.maxLoadAttempts = options.maxLoadAttempts ?? 2 + this.createNode = + options.createNode ?? + ((index: TileIndex) => { + return { + index, + state: TileState.Idle, + attempts: 0, + lastSeen: 0, + queued: false, + rendering: false + } as N + }) + ROOT_TILES.forEach((root) => this.getOrCreate(root)) + } + + getOrCreate(index: TileIndex): N { + const key = tileKey(index) + const existing = this.nodes.get(key) + if (existing) return existing + const node = this.createNode(index) + this.nodes.set(key, node) + return node + } + + /** + * Mark the ideal set for one traversal and return its size. Tiles touching + * the rectangle (plus margin) are marked on `lastSeen`; subdivision stops at + * `maxZoom`, when `shouldRefine` fails, or past `maxIdealNodes`. + */ + selectIdeal(options: SelectIdealOptions): number { + this.stamp += 1 + const margin = options.margin ?? 0 + const maxIdealNodes = options.maxIdealNodes ?? Number.POSITIVE_INFINITY + let count = 0 + const visit = (index: TileIndex): void => { + const node = this.getOrCreate(index) + const bounds = options.boundsOf(index) + if (!overlaps(bounds, options.rect, margin)) return + node.lastSeen = this.stamp + count += 1 + if (count > maxIdealNodes) return + if (index.z >= options.maxZoom) return + if (options.shouldRefine && !options.shouldRefine(index)) return + for (let quadrant = 0; quadrant < 4; quadrant += 1) { + visit(childIndex(index, quadrant)) + } + } + ROOT_TILES.forEach(visit) + return count + } + + /** + * Decide the visible tile set for the current ideal set and refresh every + * node's `rendering` flag. Returns the keys of rendered nodes. + */ + renderSet(): Set { + const visible = new Set() + const visit = (index: TileIndex, ideal: boolean): void => { + const node = this.nodes.get(tileKey(index)) + if (!node) return + const idealChildren: TileIndex[] = [] + for (let quadrant = 0; quadrant < 4; quadrant += 1) { + const child = childIndex(index, quadrant) + if (this.nodes.get(tileKey(child))?.lastSeen === this.stamp) { + idealChildren.push(child) + } + } + + if (node.state === TileState.Loaded) { + // Atomic refinement: swap to the children only when every child the + // ideal region needs is loaded. A loaded tile reached on a fallback + // path (its ideal ancestor is still downloading, e.g. an image that + // arrived out of order) paints its own footprint so the region never + // goes dark while the coarser parent is in flight. + if ( + ideal && + idealChildren.length > 0 && + idealChildren.every( + (child) => this.nodes.get(tileKey(child))?.state === TileState.Loaded + ) + ) { + idealChildren.forEach((child) => visit(child, true)) + return + } + visible.add(tileKey(index)) + return + } + + // Not loaded yet: descend looking for any loaded descendant to show. + idealChildren.forEach((child) => visit(child, false)) + } + + ROOT_TILES.forEach((root) => visit(root, true)) + + this.nodes.forEach((node) => { + node.rendering = visible.has(tileKey(node.index)) + }) + return visible + } + + /** + * Ideal nodes that still need a download, coarse zoom first so the layer + * streams from the root covers down to full detail. Failed nodes reappear + * until their retry budget is exhausted. + */ + pendingIdeal(): N[] { + const pending: N[] = [] + this.nodes.forEach((node) => { + if (node.lastSeen !== this.stamp) return + if (node.state === TileState.Idle) { + pending.push(node) + return + } + if (node.state === TileState.Failed && node.attempts < this.maxLoadAttempts) { + pending.push(node) + } + }) + pending.sort((a, b) => a.index.z - b.index.z) + return pending + } + + /** + * Evict cache-only nodes to bound memory, least-recently-seen first. Nodes + * in the current ideal set, currently rendering, downloading or queued are + * protected. Returns the evicted nodes so the caller can release their GPU + * resources; they are already removed from the pyramid. + */ + evictStale(maxTiles: number, cacheFactor = 2): N[] { + const removable: N[] = [] + this.nodes.forEach((node) => { + if ( + node.lastSeen === this.stamp || + node.rendering || + node.state === TileState.Loading || + node.queued + ) { + return + } + removable.push(node) + }) + const overflow = this.nodes.size - maxTiles * cacheFactor + if (overflow <= 0) return [] + removable.sort((a, b) => a.lastSeen - b.lastSeen) + const evicted: N[] = [] + for (let i = 0; i < Math.min(overflow, removable.length); i += 1) { + const node = removable[i] + node.state = TileState.Idle + node.rendering = false + this.nodes.delete(tileKey(node.index)) + evicted.push(node) + } + return evicted + } + + /** Drop every node; returns them so the caller can release resources. */ + clear(): N[] { + const all = Array.from(this.nodes.values()) + this.nodes.clear() + return all + } +} diff --git a/src/crs/Tiles.ts b/src/crs/Tiles.ts new file mode 100644 index 0000000..b6ac61a --- /dev/null +++ b/src/crs/Tiles.ts @@ -0,0 +1,166 @@ +import { DEG2RAD, RAD2DEG, MERCATOR_MAX_LAT } from './constants' +import type { GeoPoint } from './types' + +/** Edge length of a standard XYZ raster tile in pixels. */ +export const TILE_SIZE = 256 + +/** Slippy-map tile address: zoom level z and column/row indices x/y. */ +export interface TileIndex { + z: number + x: number + y: number +} + +/** Geographic bounding box of a tile, in degrees. */ +export interface TileBounds { + west: number + north: number + east: number + south: number +} + +/** Four geographic corners of a tile in reading order: NW, NE, SE, SW. */ +export interface TileCorners { + nw: GeoPoint + ne: GeoPoint + se: GeoPoint + sw: GeoPoint +} + +function assertZoom(zoom: number): void { + if (!Number.isInteger(zoom) || zoom < 0) { + throw new RangeError(`Tile zoom must be a non-negative integer, received ${zoom}`) + } +} + +function wrapTileX(x: number, count: number): number { + return ((x % count) + count) % count +} + +function clampTileY(y: number, count: number): number { + return Math.min(count - 1, Math.max(0, y)) +} + +/** + * Locate the XYZ tile that covers a geographic point at the given zoom. + * Longitudes wrap around the antimeridian; latitudes are clamped to the + * Web Mercator limit (~85.0511 degrees). + */ +export function lngLatToTile(lng: number, lat: number, zoom: number): TileIndex { + assertZoom(zoom) + const count = 2 ** zoom + const clampedLat = Math.min(MERCATOR_MAX_LAT, Math.max(-MERCATOR_MAX_LAT, lat)) + const x = Math.floor(((lng + 180) / 360) * count) + const y = Math.floor(((1 - Math.asinh(Math.tan(clampedLat * DEG2RAD)) / Math.PI) / 2) * count) + return { z: zoom, x: wrapTileX(x, count), y: clampTileY(y, count) } +} + +/** Inverse of lngLatToTile: geographic bounds (degrees) of a single tile. */ +export function tileToLngLatBounds(index: TileIndex): TileBounds { + const { z, x, y } = index + assertZoom(z) + const count = 2 ** z + const column = wrapTileX(x, count) + const row = clampTileY(y, count) + const west = (column / count) * 360 - 180 + const east = ((column + 1) / count) * 360 - 180 + const north = Math.atan(Math.sinh(Math.PI * (1 - (2 * row) / count))) * RAD2DEG + const south = Math.atan(Math.sinh(Math.PI * (1 - (2 * (row + 1)) / count))) * RAD2DEG + return { west, north, east, south } +} + +/** Geographic corners of a tile, ready to project onto the local plane. */ +export function tileToCorners(index: TileIndex): TileCorners { + const { west, north, east, south } = tileToLngLatBounds(index) + return { + nw: { lng: west, lat: north }, + ne: { lng: east, lat: north }, + se: { lng: east, lat: south }, + sw: { lng: west, lat: south } + } +} + +/** + * Select every tile that covers the given geographic points at one zoom level + * (the bounding rectangle of the points is filled). The math is datum-neutral: + * pass lng/lat in the same datum the tile imagery uses (WGS84 for OSM, GCJ02 + * for AMap/Tencent), and place the returned tiles with matching corners. + */ +export function selectTiles(points: GeoPoint[], zoom: number): TileIndex[] { + assertZoom(zoom) + if (!points || points.length === 0) return [] + + const count = 2 ** zoom + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + points.forEach((point) => { + const tile = lngLatToTile(point.lng, point.lat, zoom) + minX = Math.min(minX, tile.x) + maxX = Math.max(maxX, tile.x) + minY = Math.min(minY, tile.y) + maxY = Math.max(maxY, tile.y) + }) + + const tiles: TileIndex[] = [] + for (let row = minY; row <= maxY; row++) { + for (let column = minX; column <= maxX; column++) { + tiles.push({ z: zoom, x: wrapTileX(column, count), y: clampTileY(row, count) }) + } + } + return tiles +} + +/** + * Select every tile intersecting a geographic bounding rectangle given as + * west/south/east/north degrees, optionally expanded by `padding` tiles on + * every side. The rectangle may cross the antimeridian (east < west after + * normalization is handled by column wrapping); rows are clamped at the poles. + * Duplicates produced by clamping/wrapping at low zooms are removed. + */ +export function selectTilesInBounds( + west: number, + south: number, + east: number, + north: number, + zoom: number, + padding = 0 +): TileIndex[] { + assertZoom(zoom) + const count = 2 ** zoom + const nw = lngLatToTile(west, north, zoom) + const se = lngLatToTile(east, south, zoom) + + const spanX = ((se.x - nw.x + count) % count) + 1 + 2 * padding + const spanY = se.y - nw.y + 1 + 2 * padding + + const seen = new Set() + const tiles: TileIndex[] = [] + for (let dy = 0; dy < spanY; dy++) { + const row = clampTileY(nw.y - padding + dy, count) + for (let dx = 0; dx < spanX; dx++) { + const column = wrapTileX(nw.x - padding + dx, count) + const key = `${column}_${row}` + if (seen.has(key)) continue + seen.add(key) + tiles.push({ z: zoom, x: column, y: row }) + } + } + return tiles +} + +/** + * Fill an XYZ URL template. Supported tokens: {z}, {x}, {y} and the optional + * {s} subdomain placeholder (cycles deterministically through `subdomains`, + * defaulting to a/b/c). + */ +export function tileUrl(template: string, index: TileIndex, subdomains?: string[]): string { + const hosts = subdomains && subdomains.length > 0 ? subdomains : ['a', 'b', 'c'] + const subdomain = hosts[Math.abs(index.x + index.y) % hosts.length] + return template + .replace('{s}', subdomain) + .replace('{z}', String(index.z)) + .replace('{x}', String(index.x)) + .replace('{y}', String(index.y)) +} diff --git a/src/crs/constants.ts b/src/crs/constants.ts new file mode 100644 index 0000000..8d94ab5 --- /dev/null +++ b/src/crs/constants.ts @@ -0,0 +1,41 @@ +import type { AxesMapping, GeoPoint } from './types' + +/** WGS84 ellipsoid semi-major axis in metres. */ +export const WGS84_A = 6378137.0 + +/** WGS84 ellipsoid flattening derived eccentricity squared (first eccentricity). */ +export const WGS84_E2 = 0.0066943799901413165 + +/** Pi / 180 degree to radian factor. */ +export const DEG2RAD = Math.PI / 180 + +/** 180 / Pi radian to degree factor. */ +export const RAD2DEG = 180 / Math.PI + +/** Maximum latitude representable by Web Mercator (~85.05112878 degrees). */ +export const MERCATOR_MAX_LAT = 85.05112877980659 + +/** GCJ02 encryption parameters (Krasovsky 1940 ellipsoid). */ +export const GCJ_A = 6378245.0 +export const GCJ_EE = 0.006693421622965943 + +/** Default local origin used when none is supplied (longitude/latitude 0). */ +export const DEFAULT_ORIGIN: GeoPoint = { lng: 0, lat: 0, alt: 0 } + +/** + * Default axis mapping: East -> +X, North -> -Z, Up -> +Y. + * With the default camera on +Z looking toward -Z, north points up on screen. + */ +export const DEFAULT_AXES: AxesMapping = { + east: 'x', + north: '-z', + up: 'y' +} + +/** Bounding box used to decide whether GCJ02 encryption applies (mainland China). */ +export const CHINA_BOUNDS = { + lngMin: 72.004, + lngMax: 137.8347, + latMin: 0.8293, + latMax: 55.8271 +} diff --git a/src/crs/index.ts b/src/crs/index.ts new file mode 100644 index 0000000..e9e148d --- /dev/null +++ b/src/crs/index.ts @@ -0,0 +1,50 @@ +export { GeoReference, createGeoReference } from './GeoReference' +export { bindSceneGeo, getSceneGeo, ensureSceneGeo } from './SceneGeo' +export type { ScenePosition, SceneLocalPoint } from './SceneGeo' +export { createLocalPlane } from './LocalPlane' +export { mercatorProject, mercatorUnproject, mercatorCRS } from './Mercator' +export { flattenGeoJson, collectGeometries } from './GeoJson' +export { + TILE_SIZE, + lngLatToTile, + tileToLngLatBounds, + tileToCorners, + selectTiles, + selectTilesInBounds, + tileUrl +} from './Tiles' +export { + convertDatum, + toWGS84, + fromWGS84, + wgs84ToGcj02, + gcj02ToWgs84, + gcj02ToBd09, + bd09ToGcj02 +} from './Datum' +export { WGS84_A, WGS84_E2, DEFAULT_ORIGIN, DEFAULT_AXES, MERCATOR_MAX_LAT } from './constants' +export type { + GeoPoint, + DatumType, + CrsCode, + CRS, + LocalPlane, + AxesMapping, + GeoReferenceOptions +} from './types' +export type { + GeoJsonData, + GeoJsonFeature, + GeoJsonFeatureCollection, + GeoJsonGeometry, + GeoJsonGeometryData, + GeoJsonPoint, + GeoJsonMultiPoint, + GeoJsonLineString, + GeoJsonMultiLineString, + GeoJsonPolygon, + GeoJsonMultiPolygon, + GeoJsonRingSet, + GeoJsonPosition +} from './GeoJson' +export type { TileIndex, TileBounds, TileCorners } from './Tiles' diff --git a/src/crs/types.ts b/src/crs/types.ts new file mode 100644 index 0000000..83c574a --- /dev/null +++ b/src/crs/types.ts @@ -0,0 +1,79 @@ +import * as THREE from 'three' + +/** + * A geographic position expressed in degrees. + * - lng: longitude in the range [-180, 180] + * - lat: latitude in the range [-90, 90] + * - alt: ellipsoidal/orthometric height in metres above the reference surface + * (treated as a simple height above ground in the planar MVP) + */ +export interface GeoPoint { + lng: number + lat: number + alt?: number +} + +/** + * Geodetic datum / coordinate encryption used by the input or output data. + * - WGS84: raw GPS / international services + * - GCJ02: AMap (Gaode), Tencent and most Chinese services ("Mars coordinates") + * - BD09: Baidu Maps + */ +export type DatumType = 'WGS84' | 'GCJ02' | 'BD09' + +/** + * Built-in coordinate reference systems supported by the planar MVP. + * - EPSG:4326: longitude/latitude degrees, local equirectangular ENU plane + * - EPSG:3857: Spherical/Web Mercator metres + */ +export type CrsCode = 'EPSG:4326' | 'EPSG:3857' + +/** + * Mapping from the local east/north/up basis onto the three.js axes. + * Each value is a signed single axis ('x' | 'y' | 'z' | '-x' | '-y' | '-z'). + * The default maps East -> +X, North -> -Z and Up -> +Y so a camera on +Z + * looks at a scene where north points "up" on screen. + */ +export interface AxesMapping { + east: string + north: string + up: string +} + +/** + * A coordinate reference system projects geographic points to/from a global + * Cartesian frame measured in metres (using float64 precision internally). + * The projected position is NOT what is placed in the scene directly; a + * GeoReference recenters it around a local origin before handing it to three.js + * (whose float32 positions would otherwise jitter at city scale). + */ +export interface CRS { + readonly code: CrsCode + /** Project a geographic point (degrees) to a global Cartesian point in metres. */ + project(p: GeoPoint, target?: THREE.Vector3): THREE.Vector3 + /** Inverse of project: global Cartesian metres back to geographic degrees. */ + unproject(v: THREE.Vector3, target?: GeoPoint): GeoPoint +} + +/** + * A tangent-plane projection tied to a fixed local origin. All returned + * positions are relative to that origin and measured in metres, so the values + * fed to three.js stay small regardless of the city's absolute coordinates. + */ +export interface LocalPlane { + readonly origin: GeoPoint + /** Geographic point -> east/north/up offsets (metres) from the origin. */ + toENU(p: GeoPoint, target?: THREE.Vector3): THREE.Vector3 + /** east/north/up offsets (metres) back to a geographic point. */ + fromENU(enu: THREE.Vector3, target?: GeoPoint): GeoPoint +} + +/** Options accepted by the GeoReference constructor. */ +export interface GeoReferenceOptions { + /** CRS / planar model used for the tangent plane. Defaults to EPSG:4326. */ + crs?: CrsCode | CRS + /** Datum of the coordinates passed to and returned from this reference. */ + datum?: DatumType + /** Axis mapping between ENU and three.js. Defaults to E:+X, N:-Z, U:+Y. */ + axes?: AxesMapping +} diff --git a/src/index.ts b/src/index.ts index a1dcd4f..ba0f67e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,9 @@ import OBJLoader from './components/OBJLoader' import Bloom from './components/Bloom' import Rain from './components/Rain' import Snow from './components/Snow' +import GeoObject from './components/GeoObject' +import GeoJsonLayer from './components/GeoJsonLayer' +import TileLayer from './components/TileLayer' import SkyBox from './utils/SkyBox' import Popup from './utils/Popup' import Callout from './utils/Callout' @@ -25,11 +28,20 @@ import SweepLight from './utils/SweepLight' import ModelRotator from './utils/ModelRotator' import Picker from './utils/Picker' import LightPillar from './utils/LightPillar' +import { + fitCameraToBox, + fitCameraToObject, + fitCameraToGeoPoints, + fitCameraToPoints, + isGeoPoint +} from './utils/CameraFit' +import { FitCamera } from './utils/FitCamera' import { LightGradient, LightGradientOptions } from './utils/Light' import type { CalloutOptions, LineShape, BendAxis, LabelAnchor } from './utils/Callout' import type { PickEvent, PickEventType, PickCallback, PickerOptions } from './utils/Picker' import type { LightPillarOptions } from './utils/LightPillar' +import type { CameraFitOptions } from './utils/CameraFit' // components export { @@ -40,26 +52,112 @@ export { Bloom, Rain, Snow, + GeoObject, + GeoJsonLayer, + TileLayer, SceneContext, useScene } +// GIS reference system +export { + GeoReference, + createGeoReference, + bindSceneGeo, + getSceneGeo, + ensureSceneGeo, + createLocalPlane, + mercatorProject, + mercatorUnproject, + mercatorCRS, + convertDatum, + toWGS84, + fromWGS84, + wgs84ToGcj02, + gcj02ToWgs84, + gcj02ToBd09, + bd09ToGcj02, + flattenGeoJson, + collectGeometries, + TILE_SIZE, + lngLatToTile, + tileToLngLatBounds, + tileToCorners, + selectTiles, + selectTilesInBounds, + tileUrl +} from './crs/index' +export type { + GeoPoint, + DatumType, + CrsCode, + CRS, + LocalPlane, + AxesMapping, + GeoReferenceOptions +} from './crs/types' +export type { ScenePosition, SceneLocalPoint } from './crs/SceneGeo' +export type { + GeoJsonData, + GeoJsonFeature, + GeoJsonFeatureCollection, + GeoJsonGeometry, + GeoJsonGeometryData, + GeoJsonPoint, + GeoJsonMultiPoint, + GeoJsonLineString, + GeoJsonMultiLineString, + GeoJsonPolygon, + GeoJsonMultiPolygon, + GeoJsonRingSet, + GeoJsonPosition +} from './crs/GeoJson' +export type { GeoObjectProps } from './components/GeoObject' +export type { GeoJsonLayerProps, GeoJsonStyle } from './components/GeoJsonLayer' +export type { TileLayerProps } from './components/TileLayer' +export type { TileIndex, TileBounds, TileCorners } from './crs/Tiles' +export type { GridHelperOptions } from './components/Scene' + // types export type { SceneComponents, CallbackFrame, R3LRenderer } from './context/SceneContext' export type { RendererType } from './utils/Renderer' export type { LightGradientOptions, CalloutOptions, LineShape, BendAxis, LabelAnchor } export type { PickEvent, PickEventType, PickCallback, PickerOptions } export type { LightPillarOptions } +export type { CameraFitOptions } +export type { FitCameraOptions } from './utils/FitCamera' // class -export { SkyBox, Popup, Callout, Movable, WaveCircleMesh, FlowLineMesh, Animation, SweepLight, LightGradient, ModelRotator, Picker, LightPillar } +export { + SkyBox, + Popup, + Callout, + Movable, + WaveCircleMesh, + FlowLineMesh, + Animation, + SweepLight, + LightGradient, + ModelRotator, + Picker, + LightPillar, + FitCamera +} // function - async loaders (no hooks) -export { GLTFLoaderFn as GLTFLoaderAsync, FBXLoaderFn as FBXLoaderAsync, OBJLoaderFn as OBJLoaderAsync } +export { + GLTFLoaderFn as GLTFLoaderAsync, + FBXLoaderFn as FBXLoaderAsync, + OBJLoaderFn as OBJLoaderAsync +} // function - resource cleanup for models loaded through the async loaders export { disposeModel, disposeDRACOLoader } +// function - reference-system agnostic camera framing +export { fitCameraToBox, fitCameraToObject, fitCameraToGeoPoints, fitCameraToPoints, isGeoPoint } +export type { FitPoint, LocalPoint, FitPointsOptions } from './utils/CameraFit' + // enum export { AxisType } from './enums/AxisType' diff --git a/src/utils/Camera.ts b/src/utils/Camera.ts index 321f27a..17e86e0 100644 --- a/src/utils/Camera.ts +++ b/src/utils/Camera.ts @@ -1,12 +1,23 @@ import { PerspectiveCamera } from 'three' -export default function (container: HTMLElement): PerspectiveCamera { +// A geographic scene spans kilometres around its origin, so the generic 1000 m +// far plane would clip distant tiles and markers. GIS scenes create the default +// camera with this kilometre-range far plane instead. +export const GIS_CAMERA_FAR = 4000 + +export interface CameraOptions { + near?: number + far?: number +} + +export default function (container: HTMLElement, options: CameraOptions = {}): PerspectiveCamera { + const { near = 0.1, far = 1000 } = options const containerWidth = container.clientWidth const containerHeight = container.clientHeight // Guard against a zero-size container (e.g. a scene mounted inside a hidden // tab) so the aspect ratio never becomes Infinity/NaN. const aspect = containerWidth > 0 && containerHeight > 0 ? containerWidth / containerHeight : 1 - const camera = new PerspectiveCamera(75, aspect, 0.1, 1000) + const camera = new PerspectiveCamera(75, aspect, near, far) camera.position.set(0, 0, 1) camera.lookAt(0, 0, 0) return camera diff --git a/src/utils/CameraFit.ts b/src/utils/CameraFit.ts new file mode 100644 index 0000000..ae2e50f --- /dev/null +++ b/src/utils/CameraFit.ts @@ -0,0 +1,300 @@ +import * as THREE from 'three' +import type { OrbitControls } from 'three-stdlib' +import type { GeoReference } from '../crs/GeoReference' +import type { DatumType, GeoPoint } from '../crs/types' +export interface CameraFitOptions { + /** + * Viewing elevation above the horizontal plane in radians. 0 is a horizontal + * view, PI/2 is top-down. Defaults to ~45deg, which keeps a ground plane from + * occluding the markers. + */ + elevation?: number + /** + * Horizontal bearing of the camera around the target in radians, measured + * clockwise from north (-Z) like a GIS compass bearing. Defaults to PI/4. + */ + azimuth?: number + /** Multiplier applied to the computed distance; >1 zooms out for margin. */ + padding?: number + /** Override the computed distance (in local metres). */ + distance?: number + /** When true (default), derive near/far planes from the content scale. */ + adjustClip?: boolean + /** OrbitControls target is synced to the box centre when supplied. */ + controls?: OrbitControls | null +} + +/** + * A position already expressed in the local three.js metre frame. Both + * THREE.Vector3 and a plain {x, y, z} object are accepted. + */ +export type LocalPoint = THREE.Vector3 | { x: number; y: number; z?: number } + +/** Geographic or local point accepted by the unified framing entry point. */ +export type FitPoint = GeoPoint | LocalPoint + +/** + * Options for {@link fitCameraToPoints}. A geographic reference is required + * only when the input contains geographic points; it is ignored for points + * that are already in local metres. + */ +export interface FitPointsOptions extends CameraFitOptions { + /** + * GeoReference used to project geographic points to local metres. The scene + * always exposes one through onCreated/useScene, even for non-GIS scenes. + */ + geo?: GeoReference + /** Datum of geographic inputs. Defaults to the GeoReference's own datum. */ + datum?: DatumType +} + +const DEFAULT_ELEVATION = Math.PI / 4 +const DEFAULT_AZIMUTH = Math.PI / 4 +const DEFAULT_PADDING = 1.4 + +/** + * Frame a perspective camera on an axis-aligned box expressed in the same local + * metre space three.js renders in. Distance, position and (optionally) the near + * and far clipping planes are derived from the box, so callers never hard-code + * a far plane or a viewpoint that belongs to a specific data scale. + * + * Because every CRS/datum is already normalised to local metres by + * GeoReference.toLocal, this routine is reference-system agnostic. + */ +export function fitCameraToBox( + camera: THREE.PerspectiveCamera, + box: THREE.Box3, + options: CameraFitOptions = {} +): THREE.PerspectiveCamera { + const { + elevation = DEFAULT_ELEVATION, + azimuth = DEFAULT_AZIMUTH, + padding = DEFAULT_PADDING, + distance: distanceOverride, + adjustClip = true, + controls = null + } = options + + if (box.isEmpty()) return camera + + const center = box.getCenter(new THREE.Vector3()) + + const fitDistance = + distanceOverride ?? computeFitDistance(camera, box, center, elevation, azimuth) * padding + + placeCamera(camera, center, fitDistance, elevation, azimuth) + + if (adjustClip) { + // Keep the near/far ratio bounded so depth precision is not wasted. The far + // plane only has to cover the view distance plus the content radius, not a + // fixed large number, so city-scale and particle-scale scenes both fit. + const radius = box.getBoundingSphere(new THREE.Sphere()).radius + camera.near = THREE.MathUtils.clamp(fitDistance / 1000, 0.05, 10) + camera.far = (fitDistance + radius) * 4 + camera.updateProjectionMatrix() + } + + if (controls) { + controls.target.copy(center) + controls.update() + } + + // lookAt only updates the orientation quaternion; sync the world matrix so a + // caller projecting points right after fitting reads the new transform. + camera.updateMatrixWorld() + + return camera +} + +/** + * Frame a camera on the world-space bounding box of one or more objects. + */ +export function fitCameraToObject( + camera: THREE.PerspectiveCamera, + object: THREE.Object3D | THREE.Object3D[], + options: CameraFitOptions = {} +): THREE.PerspectiveCamera { + const box = new THREE.Box3() + const objects = Array.isArray(object) ? object : [object] + for (const item of objects) box.expandByObject(item) + return fitCameraToBox(camera, box, options) +} + +/** + * Runtime discriminator between geographic and local positions. A point is + * geographic when it carries longitude/latitude fields; anything with x/y is + * already in the local three.js metre frame. The two shapes never overlap, so + * no coordinate-value sniffing is needed. + */ +export function isGeoPoint(point: FitPoint): point is GeoPoint { + return ( + point !== null && + typeof point === 'object' && + typeof (point as GeoPoint).lng === 'number' && + typeof (point as GeoPoint).lat === 'number' + ) +} + +/** + * Unified framing entry point. Each point may be either a geographic + * `{lng, lat}` coordinate or a local metre position (`THREE.Vector3` / + * `{x, y, z}`); the kind is detected per point via {@link isGeoPoint}, so a + * caller never has to pick between a GIS and a non-GIS routine. Geographic + * points are projected through `options.geo` (honouring `options.datum`), + * while local points are used as-is, and both then share the same box fitting. + * Mixing the two kinds in one call is rejected because it is almost always a + * caller mistake; frame them separately instead. + */ +export function fitCameraToPoints( + camera: THREE.PerspectiveCamera, + points: FitPoint[], + options: FitPointsOptions = {} +): THREE.PerspectiveCamera { + if (points.length === 0) return camera + + const { geo, datum, ...fitOptions } = options + const box = new THREE.Box3() + const local = new THREE.Vector3() + + let kind: 'geo' | 'local' | null = null + for (const point of points) { + if (isGeoPoint(point)) { + if (!geo) { + throw new Error( + 'fitCameraToPoints: options.geo is required when framing geographic {lng, lat} points' + ) + } + if (kind === 'local') { + throw new Error( + 'fitCameraToPoints: cannot mix geographic {lng, lat} points with local {x, y, z} points' + ) + } + kind = 'geo' + geo.toLocal(point, datum, local) + box.expandByPoint(local) + } else { + if (kind === 'geo') { + throw new Error( + 'fitCameraToPoints: cannot mix geographic {lng, lat} points with local {x, y, z} points' + ) + } + kind = 'local' + local.set(point.x, point.y, (point as { z?: number }).z ?? 0) + box.expandByPoint(local) + } + } + + return fitCameraToBox(camera, box, fitOptions) +} + +/** + * Frame a camera on a set of geographic points. Coordinates are projected to + * local metres through the scene's GeoReference, which makes this usable inside + * onCreated before any children have mounted. + * + * Retained as a thin, backward-compatible wrapper; new code can use the + * coordinate-agnostic {@link fitCameraToPoints} instead. + */ +export function fitCameraToGeoPoints( + camera: THREE.PerspectiveCamera, + points: GeoPoint[], + geo: GeoReference, + datum?: DatumType, + options: CameraFitOptions = {} +): THREE.PerspectiveCamera { + return fitCameraToPoints(camera, points, { ...options, geo, datum }) +} + +/** + * Build the camera basis for a spherical viewpoint. `camDir` points from the + * target toward the camera (its Y component is +sin(elevation), so the camera + * is always above the ground), `forward = -camDir` points from the camera + * toward the target, and the camera itself sits at target + camDir*distance. + * Azimuth is measured clockwise around +Y from north (-Z), the conventional + * GIS bearing, and elevation is the angle above the ground plane. Both the fit + * distance and the placement derive from this same basis so they can never + * disagree. + */ +function cameraBasis( + elevation: number, + azimuth: number +): { + camDir: THREE.Vector3 + forward: THREE.Vector3 + right: THREE.Vector3 + up: THREE.Vector3 +} { + const cosE = Math.cos(elevation) + const camDir = new THREE.Vector3( + cosE * Math.sin(azimuth), + Math.sin(elevation), + -cosE * Math.cos(azimuth) + ).normalize() + const forward = camDir.clone().negate() + const worldUp = new THREE.Vector3(0, 1, 0) + // At elevation PI/2 camDir is parallel to worldUp and the cross product + // degenerates; keep the azimuth meaningful as an image roll instead. + const right = + cosE > 1e-6 + ? new THREE.Vector3().crossVectors(forward, worldUp).normalize() + : new THREE.Vector3(Math.cos(azimuth), 0, Math.sin(azimuth)) + const up = new THREE.Vector3().crossVectors(right, forward).normalize() + return { camDir, forward, right, up } +} + +/** + * Distance needed to keep every box corner inside the frustum. Each corner is + * expressed in the camera basis; its projected half-size must fit the horizontal + * and vertical field of view at the corner's own depth, which also accounts for + * corners that sit nearer to the camera than the target plane. + */ +function computeFitDistance( + camera: THREE.PerspectiveCamera, + box: THREE.Box3, + center: THREE.Vector3, + elevation: number, + azimuth: number +): number { + const vFov = THREE.MathUtils.degToRad(camera.fov) + const aspect = camera.aspect > 0 ? camera.aspect : 1 + const tanV = Math.tan(vFov / 2) + const tanH = Math.tan(vFov / 2) * aspect + + const { camDir, right, up } = cameraBasis(elevation, azimuth) + const corner = new THREE.Vector3() + let distance = 0 + + for (let ix = 0; ix < 2; ix++) { + for (let iy = 0; iy < 2; iy++) { + for (let iz = 0; iz < 2; iz++) { + corner.set( + ix ? box.max.x : box.min.x, + iy ? box.max.y : box.min.y, + iz ? box.max.z : box.min.z + ) + corner.sub(center) + // Depth of the corner along the target -> camera axis. A corner nearer + // the camera contributes a smaller required distance, a farther corner + // a larger one, so the maximum over all corners always encloses the box. + const depth = corner.dot(camDir) + const sx = Math.abs(corner.dot(right)) + const sy = Math.abs(corner.dot(up)) + distance = Math.max(distance, depth + sx / tanH, depth + sy / tanV) + } + } + } + + return distance +} + +function placeCamera( + camera: THREE.PerspectiveCamera, + target: THREE.Vector3, + distance: number, + elevation: number, + azimuth: number +): void { + const { camDir } = cameraBasis(elevation, azimuth) + camera.position.copy(target).addScaledVector(camDir, distance) + camera.lookAt(target) +} diff --git a/src/utils/FitCamera.ts b/src/utils/FitCamera.ts new file mode 100644 index 0000000..67742f1 --- /dev/null +++ b/src/utils/FitCamera.ts @@ -0,0 +1,87 @@ +import * as THREE from 'three' +import type { OrbitControls } from 'three-stdlib' +import type { GeoReference } from '../crs/GeoReference' +import type { DatumType } from '../crs/types' +import { fitCameraToBox, fitCameraToObject, fitCameraToPoints } from './CameraFit' +import type { CameraFitOptions, FitPoint } from './CameraFit' + +/** + * Options for framing with a {@link FitCamera}. The camera instance may carry a + * bound `geo`/`controls`, so every reference-related field stays optional here; + * a value passed per call always wins over the bound one. + */ +export interface FitCameraOptions extends CameraFitOptions { + geo?: GeoReference + datum?: DatumType +} + +/** + * A perspective camera with scene-framing helpers. It is a drop-in + * `THREE.PerspectiveCamera` (same constructor parameters, a plain native + * `position`, and the Scene's resize/controls code keeps working unchanged). + * Positioning stays coordinate-system agnostic: place it exactly like any other + * object via the scene's own helper, e.g. `scene.setPosition(camera, { lng, lat, + * alt })` — the same call that anchors a mesh — or move it in local metres with + * the normal `position.set(x, y, z)`. + * + * Bind the scene's GeoReference and `OrbitControls` with {@link FitCamera.bind} + * so the `fitTo*` framing helpers run without re-threading those references + * into every call. Pass an instance to `` and bind it + * inside `onCreated`, or construct one directly. + */ +export class FitCamera extends THREE.PerspectiveCamera { + /** Reference used to interpret geographic points passed to fitToPoints. */ + geo?: GeoReference + /** Controls whose target is synced by the framing helpers. */ + controls: OrbitControls | null = null + + constructor(fov = 75, aspect = 1, near = 0.1, far = 1000) { + super(fov, aspect, near, far) + this.position.set(0, 0, 1) + } + + /** + * Bind (or rebind) the geographic reference and controls used by later framing + * calls. Only the arguments actually passed are updated, so the two can be + * bound independently; pass `null` explicitly to clear a binding. + */ + bind(geo?: GeoReference | null, controls?: OrbitControls | null): this { + if (geo !== undefined) this.geo = geo ?? undefined + if (controls !== undefined) this.controls = controls + return this + } + + /** + * Frame a set of points; each point is either a geographic `{lng, lat}` point + * (projected through the bound/per-call geo) or a local metre + * `THREE.Vector3` / `{x, y, z}`. + */ + fitToPoints(points: FitPoint[], options: FitCameraOptions = {}): this { + const { geo = this.geo, datum, controls, ...fitOptions } = options + fitCameraToPoints(this, points, { + ...fitOptions, + controls: controls ?? this.controls, + ...(geo !== undefined ? { geo } : {}), + ...(datum !== undefined ? { datum } : {}) + }) + return this + } + + /** Frame the world-space bounding box of one or more objects. */ + fitToObject(object: THREE.Object3D | THREE.Object3D[], options: CameraFitOptions = {}): this { + fitCameraToObject(this, object, { + ...options, + controls: options.controls ?? this.controls + }) + return this + } + + /** Frame an explicit axis-aligned box already expressed in local metres. */ + fitToBox(box: THREE.Box3, options: CameraFitOptions = {}): this { + fitCameraToBox(this, box, { + ...options, + controls: options.controls ?? this.controls + }) + return this + } +} diff --git a/src/utils/GroundSampling.ts b/src/utils/GroundSampling.ts new file mode 100644 index 0000000..e6f9ea7 --- /dev/null +++ b/src/utils/GroundSampling.ts @@ -0,0 +1,74 @@ +import * as THREE from 'three' + +/** + * Ground coverage is capped to this multiple of the camera's height above the + * ground. At grazing angles frustum corner rays meet the ground dozens of + * kilometres away (or never), so without a cap the tile selection would fan + * out towards the horizon instead of staying around the viewer. The cap is + * also where distance fog reaches full opacity, so the rectangular tile edge + * dissolves into the background without a seam. + */ +export const MAX_GROUND_RANGE_FACTOR = 6 + +/** + * Sample the ground the camera can actually see, the way mature engines + * (Cesium/MapLibre) derive their tile cover: cast rays through the four + * frustum corners plus the view centre onto the ground plane and return the + * hit points; the caller takes their bounding box. + * + * - A ray that meets the ground beyond the range cap is clamped along its + * bearing to the cap (the far plane would clip anything farther, and every + * chosen quad would go dark). + * - A ray at/above the horizon cannot see ground along that edge. It + * collapses to the ground point straight below the camera - the nearest + * ground the frame still contains - never to the range cap: pushing a + * skyward corner to the cap tears the coverage rectangle far past the + * on-screen ground, which leaves the near field un-tiled and shows the map + * only as a small patch while most of the frame stays empty. + * - The view-centre ray is included as a floor: whatever the corners do, the + * point the camera looks at is always covered. + */ +export function sampleVisibleGround( + camera: THREE.PerspectiveCamera, + groundY: number, + maxRangeFactor = MAX_GROUND_RANGE_FACTOR +): THREE.Vector3[] { + const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), -groundY) + const raycaster = new THREE.Raycaster() + const probes: [number, number][] = [ + [-1, -1], + [1, -1], + [1, 1], + [-1, 1], + [0, 0] + ] + + const height = Math.max(camera.position.y - groundY, 1e-6) + const horizonRange = height * maxRangeFactor + // Largest horizontal distance at which the frustum can still reach the + // ground: the far sphere meets the ground plane sqrt(far^2 - h^2) away. + const farRange = camera.far > height ? Math.sqrt(camera.far * camera.far - height * height) : 0 + const maxRange = farRange > 0 ? Math.min(horizonRange, farRange) : horizonRange + + const groundBelowCamera = new THREE.Vector3(camera.position.x, groundY, camera.position.z) + + return probes.map(([ndcX, ndcY]) => { + raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera) + const hit = new THREE.Vector3() + if (!raycaster.ray.intersectPlane(plane, hit)) { + // Skyward ray: collapse to the ground under the camera. + return groundBelowCamera.clone() + } + const dx = hit.x - camera.position.x + const dz = hit.z - camera.position.z + const horizontalDistance = Math.hypot(dx, dz) + if (horizontalDistance <= maxRange) return hit + // Far/grazing hit: clamp to the range cap along the same ground bearing. + const scale = maxRange / horizontalDistance + return new THREE.Vector3( + camera.position.x + dx * scale, + groundY, + camera.position.z + dz * scale + ) + }) +} diff --git a/src/utils/Scene.ts b/src/utils/Scene.ts index 49e0a14..7527f9b 100644 --- a/src/utils/Scene.ts +++ b/src/utils/Scene.ts @@ -6,6 +6,7 @@ import { PMREMGenerator as NodePMREMGenerator } from 'three/webgpu' import { PMREMGenerator as LegacyPMREMGenerator } from 'three' import { OrbitControls } from 'three-stdlib' import CSS2DRenderer from './CSS2DRenderer' +import { bindSceneGeo } from '../crs/SceneGeo' import type { SceneComponents, CallbackFrame, R3LRenderer } from '../context/SceneContext' export default async function ( @@ -24,6 +25,11 @@ export default async function ( // such as color output. scene.userData.renderer = renderer + // Always install scene.setPosition so cameras and objects can be placed + // without threading the reference around. With no GeoReference the helper + // still works, treating lng/lat/alt (and x/y/z) as plain local metres. + bindSceneGeo(scene, components.geo) + if (components.light) { scene.add(components.light) } @@ -75,13 +81,17 @@ export default async function ( if (isNodeRenderer) { // Node-based PMREM — works directly with WebGPURenderer. - const pmrem = new NodePMREMGenerator(renderer as unknown as ConstructorParameters[0]) + const pmrem = new NodePMREMGenerator( + renderer as unknown as ConstructorParameters[0] + ) defaultEnvTarget = pmrem.fromScene(envScene, 0.04) scene.environment = defaultEnvTarget.texture pmrem.dispose() } else { // Legacy WebGLRenderer path — use the old PMREMGenerator. - const pmrem = new LegacyPMREMGenerator(renderer as unknown as ConstructorParameters[0]) + const pmrem = new LegacyPMREMGenerator( + renderer as unknown as ConstructorParameters[0] + ) pmrem.compileEquirectangularShader() defaultEnvTarget = pmrem.fromScene(envScene, 0.04) scene.environment = defaultEnvTarget.texture @@ -149,6 +159,12 @@ export default async function ( updateSize() }) resizeObserver.observe(container) + // The first ResizeObserver callback is async, but onCreated runs right after + // this setup and a caller-supplied camera (e.g. a FitCamera framing content + // in onCreated) needs the real aspect immediately. Sync once up front; the + // call is idempotent and a camera created by the default factory already has + // the same aspect. + updateSize() window.addEventListener('resize', updateSize) From 4aa47bdbb197db29a21b4ef67362a8d9d45a7127 Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Thu, 8 Oct 2026 08:58:59 +0800 Subject: [PATCH 05/13] chore: release version 0.6.0 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc384b..6c0c2a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## [Unreleased] +## [0.6.0] - 2026-10-08 ### Added diff --git a/package.json b/package.json index bd14574..0377274 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-three-lite", "type": "module", - "version": "0.3.0", + "version": "0.6.0", "author": "Shing Rui ", "repository": { "type": "git", From e93305d78233a54353273f8c6976f5f0cea2a044 Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Thu, 8 Oct 2026 09:03:55 +0800 Subject: [PATCH 06/13] docs(agents): add release workflow rules --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 71d0322..183b31b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -242,6 +242,30 @@ pnpm format # Format with Prettier pnpm build # Build for production ``` +### Releasing + +Releases are cut from `main`, never directly from `develop`. Follow this order +exactly - the tag is the last step because pushing it triggers the +`Create Release` workflow (GitHub Release, NPM publish, docs deploy): + +1. Update `CHANGELOG.md`: rename the `## [Unreleased]` heading to + `## [x.y.z] - YYYY-MM-DD` (keep the accumulated sections; do not use the + placeholder the release script injects). +2. Bump the version: update `package.json` with the target version, or run + `node scripts/release.js x.y.z` - but if the script creates a tag, delete it + (`git tag -d x.y.z`); the script commits `package.json`/`CHANGELOG.md`, which + is fine. +3. Commit the release preparation on `develop` + (`chore: release version x.y.z`). +4. Push `develop` and open a pull request from `develop` to `main`. +5. After the PR is reviewed and merged into `main`, create the tag on the + `main` merge commit (`git checkout main && git pull && git tag x.y.z`) and + push only then (`git push origin x.y.z`). +6. Verify the `Create Release` GitHub Actions run completes. + +Never create or push a release tag before the code is merged to `main`, and +never run `pnpm publish` manually. + ## Common Patterns ### Scene Context Usage From b5e2e99e34cfd2a5759e45ff9e9545dbab7230dd Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Thu, 8 Oct 2026 22:34:32 +0800 Subject: [PATCH 07/13] style(demos): unify background to rgb(40, 42, 54) - Replace the #1a1a2e scene background with rgb(40, 42, 54) across all demo components, including the equivalent hex literal used by the Bloom demo onCreated handler - Update the matching code samples in the English and Chinese docs - Update the default demo background convention in AGENTS.md --- AGENTS.md | 8 ++++---- docs/guide/animation.md | 4 ++-- docs/guide/callout.md | 4 ++-- docs/guide/effects/light-pillar.md | 4 ++-- docs/guide/effects/rain.md | 4 ++-- docs/guide/effects/snow.md | 4 ++-- docs/guide/effects/sweep-light.md | 4 ++-- docs/guide/gis/geo-object.md | 4 ++-- docs/guide/gis/geo-reference.md | 4 ++-- docs/guide/gis/geojson-layer.md | 4 ++-- docs/guide/gis/tile-layer.md | 4 ++-- docs/guide/model-rotator.md | 4 ++-- docs/guide/picking.md | 2 +- .../docusaurus-plugin-content-docs/current/animation.md | 4 ++-- .../docusaurus-plugin-content-docs/current/callout.md | 4 ++-- .../current/effects/light-pillar.md | 4 ++-- .../current/effects/rain.md | 4 ++-- .../current/effects/snow.md | 4 ++-- .../current/effects/sweep-light.md | 4 ++-- .../current/gis/geo-object.md | 4 ++-- .../current/gis/geo-reference.md | 4 ++-- .../current/gis/geojson-layer.md | 4 ++-- .../current/gis/tile-layer.md | 4 ++-- .../current/model-rotator.md | 4 ++-- .../docusaurus-plugin-content-docs/current/picking.md | 2 +- docs/src/components/Animation.tsx | 2 +- docs/src/components/Callout.tsx | 2 +- docs/src/components/FBXLoaderComponent.tsx | 2 +- docs/src/components/FBXLoaderFunction.tsx | 2 +- docs/src/components/GLTFLoaderComponent.tsx | 2 +- docs/src/components/GLTFLoaderFunction.tsx | 2 +- docs/src/components/ModelRotator.tsx | 2 +- docs/src/components/MovableElement.tsx | 2 +- docs/src/components/OBJLoaderComponent.tsx | 2 +- docs/src/components/OBJLoaderFunction.tsx | 2 +- docs/src/components/Picking.tsx | 2 +- docs/src/components/effects/Bloom.tsx | 4 ++-- docs/src/components/effects/LightGradient.tsx | 2 +- docs/src/components/effects/LightPillar.tsx | 2 +- docs/src/components/effects/Rain.tsx | 2 +- docs/src/components/effects/Snow.tsx | 2 +- docs/src/components/effects/SweepLight.tsx | 2 +- docs/src/components/gis/GeoJsonLayer.tsx | 2 +- docs/src/components/gis/GeoObject.tsx | 2 +- docs/src/components/gis/GeoReference.tsx | 2 +- docs/src/components/gis/TileLayer.tsx | 2 +- docs/src/components/meshes/FlowLineMesh.tsx | 2 +- docs/src/components/meshes/FlowLineMeshOptions.tsx | 2 +- docs/src/components/meshes/WaveCircleMesh.tsx | 2 +- docs/src/components/meshes/WaveCircleMeshOptions.tsx | 2 +- 50 files changed, 76 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 183b31b..ccc6e33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,7 +135,7 @@ useEffect(() => { - Use `onCreated` callback on `` to initialize effects (not `useScene()` hook) - Set camera position in `handleCreated`: `camera.position.set(0, 0, 4)` - Use `camera.lookAt(0, 0, 0)` to ensure proper orientation -- **Default Scene background color: `bgColor="#1a1a2e"`** — use this for all demo Scenes by default. +- **Default Scene background color: `bgColor="rgb(40, 42, 54)"`** — use this for all demo Scenes by default. - Set demo container style: `style={{ marginTop: '10px', marginBottom: '16px', width: '100%', height: '300px' }}` - Add cleanup in `useEffect` return function and set refs to `null` - Use `// Cleanup on unmount` comment before useEffect @@ -173,7 +173,7 @@ export default function App() { }, []) return ( - + ) } ``` @@ -213,7 +213,7 @@ When creating particle effects like Rain or Snow, follow this workflow: **5. Create Demo Component** - Set front-facing camera: `camera.position.set(0, 0, 4)` - Use `camera.lookAt(0, 0, 0)` to ensure proper orientation -- Default Scene background color: `bgColor="#1a1a2e"` — use it for all demos by default +- Default Scene background color: `bgColor="rgb(40, 42, 54)"` — use it for all demos by default - Adjust camera distance for visibility - Set demo container style: `style={{ marginTop: '10px', marginBottom: '16px', width: '100%', height: '300px' }}` @@ -395,7 +395,7 @@ function Component({ rendererType }: { rendererType?: 'webgpu' | }, []) return ( - + ) } ``` diff --git a/docs/guide/animation.md b/docs/guide/animation.md index 8caf876..5a3241b 100644 --- a/docs/guide/animation.md +++ b/docs/guide/animation.md @@ -37,7 +37,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( ) @@ -66,7 +66,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** ) diff --git a/docs/guide/callout.md b/docs/guide/callout.md index 6f13f9d..35d8d96 100644 --- a/docs/guide/callout.md +++ b/docs/guide/callout.md @@ -84,7 +84,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( @@ -160,7 +160,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( diff --git a/docs/guide/effects/light-pillar.md b/docs/guide/effects/light-pillar.md index a9ec506..ad3ed63 100644 --- a/docs/guide/effects/light-pillar.md +++ b/docs/guide/effects/light-pillar.md @@ -66,7 +66,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( @@ -121,7 +121,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( + ) @@ -39,7 +39,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** export default function App() { return ( - + ) diff --git a/docs/guide/effects/snow.md b/docs/guide/effects/snow.md index e5edb80..db9666d 100644 --- a/docs/guide/effects/snow.md +++ b/docs/guide/effects/snow.md @@ -24,7 +24,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** export default function App() { return ( - + ) @@ -39,7 +39,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** export default function App() { return ( - + ) diff --git a/docs/guide/effects/sweep-light.md b/docs/guide/effects/sweep-light.md index a5bcfbc..834dc82 100644 --- a/docs/guide/effects/sweep-light.md +++ b/docs/guide/effects/sweep-light.md @@ -46,7 +46,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( @@ -83,7 +83,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** return ( {MARKERS.map((coordinate, index) => ( @@ -132,7 +132,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the origin={ORIGIN} gridHelper={grid} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} > {MARKERS.map((coordinate, index) => ( diff --git a/docs/guide/gis/geo-reference.md b/docs/guide/gis/geo-reference.md index a339889..9449ec2 100644 --- a/docs/guide/gis/geo-reference.md +++ b/docs/guide/gis/geo-reference.md @@ -109,7 +109,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the origin={ORIGIN} gridHelper={grid} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} /> ) @@ -174,7 +174,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the rendererType="webgl" origin={ORIGIN} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} /> ) diff --git a/docs/guide/gis/geojson-layer.md b/docs/guide/gis/geojson-layer.md index 99139a8..a114008 100644 --- a/docs/guide/gis/geojson-layer.md +++ b/docs/guide/gis/geojson-layer.md @@ -96,7 +96,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the origin={ORIGIN} gridHelper={grid} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} > @@ -135,7 +135,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the rendererType="webgl" origin={ORIGIN} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} > diff --git a/docs/guide/model-rotator.md b/docs/guide/model-rotator.md index 85419f0..9b9af00 100644 --- a/docs/guide/model-rotator.md +++ b/docs/guide/model-rotator.md @@ -55,7 +55,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** }, []) return ( - + ) } ``` @@ -98,7 +98,7 @@ Every example below can be viewed with either the **WebGPU** (default) or the ** }, []) return ( - + ) } ``` diff --git a/docs/guide/picking.md b/docs/guide/picking.md index 01e78f4..7f9ff74 100644 --- a/docs/guide/picking.md +++ b/docs/guide/picking.md @@ -74,7 +74,7 @@ function App({ rendererType }: { rendererType?: 'webgpu' | 'webgl' }) { onCreated={handleCreated} onClick={handleClick} onHover={handleHover} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} /> ) diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/animation.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/animation.md index 0adb81a..4954f0e 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/animation.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/animation.md @@ -36,7 +36,7 @@ import Animation from '@site/src/components/Animation' return ( ) @@ -65,7 +65,7 @@ import Animation from '@site/src/components/Animation' ) diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/callout.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/callout.md index bb64926..f2b5fd6 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/callout.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/callout.md @@ -82,7 +82,7 @@ import Callout from '@site/src/components/Callout' return ( @@ -157,7 +157,7 @@ import Callout from '@site/src/components/Callout' return ( diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md index 23b03a5..7505cd1 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/light-pillar.md @@ -65,7 +65,7 @@ import LightPillarComponent from '@site/src/components/effects/LightPillar' return ( @@ -120,7 +120,7 @@ import LightPillarComponent from '@site/src/components/effects/LightPillar' return ( + ) @@ -38,7 +38,7 @@ import RainComponent from '@site/src/components/effects/Rain' export default function App() { return ( - + ) diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/snow.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/snow.md index bfb8609..a9590e4 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/snow.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/snow.md @@ -23,7 +23,7 @@ import SnowComponent from '@site/src/components/effects/Snow' export default function App() { return ( - + ) @@ -38,7 +38,7 @@ import SnowComponent from '@site/src/components/effects/Snow' export default function App() { return ( - + ) diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/sweep-light.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/sweep-light.md index 33de7e8..4525184 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/sweep-light.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/sweep-light.md @@ -45,7 +45,7 @@ import SweepLightComponent from '@site/src/components/effects/SweepLight' return ( @@ -82,7 +82,7 @@ import SweepLightComponent from '@site/src/components/effects/SweepLight' return ( {MARKERS.map((coordinate, index) => ( @@ -129,7 +129,7 @@ import App from '@site/src/components/gis/GeoObject' origin={ORIGIN} gridHelper={grid} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} > {MARKERS.map((coordinate, index) => ( diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md index 9bdf01c..f831c35 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geo-reference.md @@ -100,7 +100,7 @@ import App from '@site/src/components/gis/GeoReference' origin={ORIGIN} gridHelper={grid} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} /> ) @@ -164,7 +164,7 @@ import App from '@site/src/components/gis/GeoReference' rendererType="webgl" origin={ORIGIN} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} /> ) diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md index 9e4015e..71f2af9 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/gis/geojson-layer.md @@ -93,7 +93,7 @@ import App from '@site/src/components/gis/GeoJsonLayer' origin={ORIGIN} gridHelper={grid} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} > @@ -122,7 +122,7 @@ OSM 影像以 WGS84 索引,因此使用 Scene 默认 datum 即可。 origin={ORIGIN} gridHelper={false} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} > diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/model-rotator.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/model-rotator.md index c58caf1..48361db 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/model-rotator.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/model-rotator.md @@ -54,7 +54,7 @@ import ModelRotator from '@site/src/components/ModelRotator' }, []) return ( - + ) } ``` @@ -97,7 +97,7 @@ import ModelRotator from '@site/src/components/ModelRotator' }, []) return ( - + ) } ``` diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md index c72aa0a..bfa219f 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/picking.md @@ -71,7 +71,7 @@ function App({ rendererType }: { rendererType?: 'webgpu' | 'webgl' }) { onCreated={handleCreated} onClick={handleClick} onHover={handleHover} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', width: '100%', height: '300px' }} /> ) diff --git a/docs/src/components/Animation.tsx b/docs/src/components/Animation.tsx index d0dd13a..fb6d844 100644 --- a/docs/src/components/Animation.tsx +++ b/docs/src/components/Animation.tsx @@ -24,6 +24,6 @@ export default function AnimationComponent({ rendererType = 'webgpu' }: Animatio } return ( - + ) } diff --git a/docs/src/components/Callout.tsx b/docs/src/components/Callout.tsx index 6999eba..c8c2d2f 100644 --- a/docs/src/components/Callout.tsx +++ b/docs/src/components/Callout.tsx @@ -71,7 +71,7 @@ export default function App({ rendererType = 'webgpu' }: CalloutProps = {}) { return ( diff --git a/docs/src/components/FBXLoaderComponent.tsx b/docs/src/components/FBXLoaderComponent.tsx index 27ab312..3eac7e7 100644 --- a/docs/src/components/FBXLoaderComponent.tsx +++ b/docs/src/components/FBXLoaderComponent.tsx @@ -24,7 +24,7 @@ export default function FBXLoaderComponentComponent({ rendererType }: FBXLoaderC }, []) return ( - + {FBXLoader && } ) diff --git a/docs/src/components/FBXLoaderFunction.tsx b/docs/src/components/FBXLoaderFunction.tsx index 4d2f64f..17101bc 100644 --- a/docs/src/components/FBXLoaderFunction.tsx +++ b/docs/src/components/FBXLoaderFunction.tsx @@ -21,7 +21,7 @@ export default function FBXLoaderFunctionComponent({ rendererType }: FBXLoaderFu }, []) return ( - + ) } diff --git a/docs/src/components/GLTFLoaderComponent.tsx b/docs/src/components/GLTFLoaderComponent.tsx index e95b595..9b4f615 100644 --- a/docs/src/components/GLTFLoaderComponent.tsx +++ b/docs/src/components/GLTFLoaderComponent.tsx @@ -24,7 +24,7 @@ export default function GLTFLoaderComponentComponent({ rendererType }: GLTFLoade }, []) return ( - + {GLTFLoader && } ) diff --git a/docs/src/components/GLTFLoaderFunction.tsx b/docs/src/components/GLTFLoaderFunction.tsx index ba6df1c..759d1a0 100644 --- a/docs/src/components/GLTFLoaderFunction.tsx +++ b/docs/src/components/GLTFLoaderFunction.tsx @@ -21,7 +21,7 @@ export default function GLTFLoaderFunctionComponent({ rendererType }: GLTFLoader }, []) return ( - + ) } diff --git a/docs/src/components/ModelRotator.tsx b/docs/src/components/ModelRotator.tsx index c69e605..b5f46b9 100644 --- a/docs/src/components/ModelRotator.tsx +++ b/docs/src/components/ModelRotator.tsx @@ -37,6 +37,6 @@ export default function ModelRotatorComponent({ rendererType = 'webgpu' }: Model }, []) return ( - + ) } diff --git a/docs/src/components/MovableElement.tsx b/docs/src/components/MovableElement.tsx index 793cee1..aeace33 100644 --- a/docs/src/components/MovableElement.tsx +++ b/docs/src/components/MovableElement.tsx @@ -32,7 +32,7 @@ export default function MovableElementComponent({ rendererType = 'webgpu' }: Mov ) diff --git a/docs/src/components/OBJLoaderComponent.tsx b/docs/src/components/OBJLoaderComponent.tsx index 97eebf2..1af1e8a 100644 --- a/docs/src/components/OBJLoaderComponent.tsx +++ b/docs/src/components/OBJLoaderComponent.tsx @@ -24,7 +24,7 @@ export default function OBJLoaderComponentComponent({ rendererType }: OBJLoaderC }, []) return ( - + {OBJLoader && ( + ) } diff --git a/docs/src/components/Picking.tsx b/docs/src/components/Picking.tsx index adcc7ae..03f4a8e 100644 --- a/docs/src/components/Picking.tsx +++ b/docs/src/components/Picking.tsx @@ -62,7 +62,7 @@ export default function PickingComponent({ rendererType }: PickingProps) { onCreated={handleCreated} onClick={handleClick} onHover={handleHover} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', marginBottom: '16px', width: '100%', height: '300px' }} /> ) diff --git a/docs/src/components/effects/Bloom.tsx b/docs/src/components/effects/Bloom.tsx index 1695528..f95e184 100644 --- a/docs/src/components/effects/Bloom.tsx +++ b/docs/src/components/effects/Bloom.tsx @@ -14,7 +14,7 @@ export default function BloomComponent({ rendererType }: BloomProps) { camera.position.set(0, 1.5, 3) // Set background color for better visibility - scene.background = new THREE.Color(0x1a1a2e) + scene.background = new THREE.Color('rgb(40, 42, 54)') const geometry = new THREE.BoxGeometry() @@ -35,7 +35,7 @@ export default function BloomComponent({ rendererType }: BloomProps) { return (
- +
diff --git a/docs/src/components/effects/LightGradient.tsx b/docs/src/components/effects/LightGradient.tsx index 5459a21..a5b0031 100644 --- a/docs/src/components/effects/LightGradient.tsx +++ b/docs/src/components/effects/LightGradient.tsx @@ -63,6 +63,6 @@ export default function LightGradientComponent({ rendererType }: LightGradientPr }, []) return ( - + ) } diff --git a/docs/src/components/effects/LightPillar.tsx b/docs/src/components/effects/LightPillar.tsx index 833b2b4..0711116 100644 --- a/docs/src/components/effects/LightPillar.tsx +++ b/docs/src/components/effects/LightPillar.tsx @@ -45,6 +45,6 @@ export default function LightPillarComponent({ rendererType }: LightPillarProps) }, []) return ( - + ) } diff --git a/docs/src/components/effects/Rain.tsx b/docs/src/components/effects/Rain.tsx index 63bd8a1..deebbd6 100644 --- a/docs/src/components/effects/Rain.tsx +++ b/docs/src/components/effects/Rain.tsx @@ -15,7 +15,7 @@ export default function RainComponent({ rendererType }: RainProps) { } return ( - + ) diff --git a/docs/src/components/effects/Snow.tsx b/docs/src/components/effects/Snow.tsx index e0f8b27..ba30b5e 100644 --- a/docs/src/components/effects/Snow.tsx +++ b/docs/src/components/effects/Snow.tsx @@ -15,7 +15,7 @@ export default function SnowComponent({ rendererType }: SnowProps) { } return ( - + ) diff --git a/docs/src/components/effects/SweepLight.tsx b/docs/src/components/effects/SweepLight.tsx index 2e4630f..b441b42 100644 --- a/docs/src/components/effects/SweepLight.tsx +++ b/docs/src/components/effects/SweepLight.tsx @@ -33,6 +33,6 @@ export default function SweepLightComponent({ rendererType }: SweepLightProps) { }, []) return ( - + ) } diff --git a/docs/src/components/gis/GeoJsonLayer.tsx b/docs/src/components/gis/GeoJsonLayer.tsx index a6861a1..669ee84 100644 --- a/docs/src/components/gis/GeoJsonLayer.tsx +++ b/docs/src/components/gis/GeoJsonLayer.tsx @@ -90,7 +90,7 @@ export default function App({ rendererType }: AppProps) { origin={ORIGIN} gridHelper={GRID} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', marginBottom: '16px', width: '100%', height: '300px' }} > {MARKERS.map((coordinate, index) => ( diff --git a/docs/src/components/gis/GeoReference.tsx b/docs/src/components/gis/GeoReference.tsx index 789f82d..682c167 100644 --- a/docs/src/components/gis/GeoReference.tsx +++ b/docs/src/components/gis/GeoReference.tsx @@ -66,7 +66,7 @@ export default function App({ rendererType }: AppProps) { origin={ORIGIN} gridHelper={GRID} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', marginBottom: '16px', width: '100%', height: '300px' }} /> ) diff --git a/docs/src/components/gis/TileLayer.tsx b/docs/src/components/gis/TileLayer.tsx index e739245..7423904 100644 --- a/docs/src/components/gis/TileLayer.tsx +++ b/docs/src/components/gis/TileLayer.tsx @@ -52,7 +52,7 @@ export default function App({ rendererType }: AppProps) { origin={ORIGIN} gridHelper={false} onCreated={handleCreated} - bgColor="#1a1a2e" + bgColor="rgb(40, 42, 54)" style={{ marginTop: '10px', marginBottom: '16px', width: '100%', height: '300px' }} > diff --git a/docs/src/components/meshes/FlowLineMesh.tsx b/docs/src/components/meshes/FlowLineMesh.tsx index 169d6e1..c2c7f78 100644 --- a/docs/src/components/meshes/FlowLineMesh.tsx +++ b/docs/src/components/meshes/FlowLineMesh.tsx @@ -55,7 +55,7 @@ export default function FlowLineMeshComponent({ rendererType }: FlowLineMeshProp return (
- +
) } diff --git a/docs/src/components/meshes/FlowLineMeshOptions.tsx b/docs/src/components/meshes/FlowLineMeshOptions.tsx index 5635c87..f430a1f 100644 --- a/docs/src/components/meshes/FlowLineMeshOptions.tsx +++ b/docs/src/components/meshes/FlowLineMeshOptions.tsx @@ -56,7 +56,7 @@ export default function FlowLineMeshOptionsComponent({ rendererType }: FlowLineM return (
- +
) } diff --git a/docs/src/components/meshes/WaveCircleMesh.tsx b/docs/src/components/meshes/WaveCircleMesh.tsx index 970596c..e2179e0 100644 --- a/docs/src/components/meshes/WaveCircleMesh.tsx +++ b/docs/src/components/meshes/WaveCircleMesh.tsx @@ -23,7 +23,7 @@ export default function WaveCircleMeshComponent({ rendererType }: WaveCircleMesh return (
- +
) } diff --git a/docs/src/components/meshes/WaveCircleMeshOptions.tsx b/docs/src/components/meshes/WaveCircleMeshOptions.tsx index 63d2e9f..b7666f7 100644 --- a/docs/src/components/meshes/WaveCircleMeshOptions.tsx +++ b/docs/src/components/meshes/WaveCircleMeshOptions.tsx @@ -29,7 +29,7 @@ export default function WaveCircleMeshOptionsComponent({ rendererType }: WaveCir return (
- +
) } From c3a59b488020767291ea74c6a2e865bb8964f84e Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Thu, 15 Oct 2026 09:13:22 +0800 Subject: [PATCH 08/13] feat(effects): add height fog and fireworks effects - Add HeightFog screen-space height fog effect with WebGPU/WebGL - Add Fireworks particle effect with docs and demos - Add EN/ZH effect docs and sidebar entries - Fix Picking doc frontmatter delimiters - Normalize source formatting to match Prettier config --- docs/guide/effects/fireworks.md | 171 +++++ docs/guide/effects/height-fog.md | 82 ++ docs/guide/picking.md | 5 +- .../current.json | 8 + .../current/effects/fireworks.md | 170 +++++ .../current/effects/height-fog.md | 81 ++ docs/sidebars.js | 2 +- docs/src/components/effects/Fireworks.tsx | 71 ++ docs/src/components/effects/HeightFog.tsx | 13 + src/__tests__/Datum.test.ts | 8 +- src/__tests__/Fireworks.test.ts | 191 +++++ src/__tests__/FlowLineMesh.test.ts | 10 +- src/__tests__/GeoReference.test.ts | 4 +- src/__tests__/LightPillar.test.ts | 18 +- src/__tests__/TileLayer.integration.test.ts | 10 +- src/__tests__/api.test.ts | 4 + src/components/FBXLoader.tsx | 4 +- src/components/GLTFLoader.tsx | 16 +- src/components/GeoJsonLayer.tsx | 20 +- src/components/HeightFog.tsx | 415 ++++++++++ src/components/OBJLoader.tsx | 4 +- src/components/Rain.tsx | 7 +- src/components/Snow.tsx | 7 +- src/components/TileLayer.tsx | 24 +- src/crs/Datum.ts | 14 +- src/crs/GeoReference.ts | 9 +- src/crs/TilePyramid.ts | 4 +- src/index.ts | 6 + src/meshes/FlowLineMesh.ts | 43 +- src/meshes/WaveCircleMesh.ts | 10 +- src/utils/Animation.ts | 19 +- src/utils/AxesHelper.ts | 2 +- src/utils/CSS2DRenderer.ts | 6 +- src/utils/Callout.ts | 26 +- src/utils/Fireworks.ts | 708 ++++++++++++++++++ src/utils/Light.ts | 11 +- src/utils/LightPillar.ts | 29 +- src/utils/ModelLoader.ts | 50 +- src/utils/Picker.ts | 8 +- src/utils/SweepLight.ts | 44 +- 40 files changed, 2148 insertions(+), 186 deletions(-) create mode 100644 docs/guide/effects/fireworks.md create mode 100644 docs/guide/effects/height-fog.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/fireworks.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md create mode 100644 docs/src/components/effects/Fireworks.tsx create mode 100644 docs/src/components/effects/HeightFog.tsx create mode 100644 src/__tests__/Fireworks.test.ts create mode 100644 src/components/HeightFog.tsx create mode 100644 src/utils/Fireworks.ts diff --git a/docs/guide/effects/fireworks.md b/docs/guide/effects/fireworks.md new file mode 100644 index 0000000..a6c5acf --- /dev/null +++ b/docs/guide/effects/fireworks.md @@ -0,0 +1,171 @@ +--- +id: fireworks +lang: en-US +title: Fireworks +--- + +## Type + +Class + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import Fireworks from '@site/src/components/effects/Fireworks' + +A fireworks shell built entirely in the shader. A single launch streak rises from the origin, then bursts into a spherical spray of gravity-bent ribbon trails whose heads glow and whose tails dissolve through value noise. One `Fireworks` instance is one shell — add several with different colors, heights and phases to script a full show. + +## Default Usage + +Every example below can be viewed with either the **WebGPU** (default) or the **WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene, Fireworks } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const fireworksRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 6, 17) + camera.lookAt(0, 6, 0) + + const shells = [ + { color: 0xffd4a0, height: 6.6, scale: 3.6, offset: [-3.6, 0, -1.5], launchDuration: 1.25, burstDuration: 1.9 }, + { color: 0x7ad7ff, height: 5.9, scale: 3.9, offset: [-1.4, 0, 1.6], launchDuration: 1.7, burstDuration: 1.6 }, + { color: 0xff6b6b, height: 7.4, scale: 3.2, offset: [3.2, 0, -0.8], launchDuration: 1.5, burstDuration: 2.2 } + ] + + shells.forEach((shell) => { + const fireworks = new Fireworks({ + color: shell.color, + burstHeight: shell.height, + burstScale: shell.scale, + burstPositionOffset: new THREE.Vector3(...shell.offset), + launchDuration: shell.launchDuration, + burstDuration: shell.burstDuration, + loop: true + }) + scene.add(fireworks) + fireworksRef.current.push(fireworks) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + fireworksRef.current.forEach((fireworks) => fireworks.dispose()) + fireworksRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene, Fireworks } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const fireworksRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 6, 17) + camera.lookAt(0, 6, 0) + + const shells = [ + { color: 0xffd4a0, height: 6.6, scale: 3.6, offset: [-3.6, 0, -1.5], launchDuration: 1.25, burstDuration: 1.9 }, + { color: 0x7ad7ff, height: 5.9, scale: 3.9, offset: [-1.4, 0, 1.6], launchDuration: 1.7, burstDuration: 1.6 }, + { color: 0xff6b6b, height: 7.4, scale: 3.2, offset: [3.2, 0, -0.8], launchDuration: 1.5, burstDuration: 2.2 } + ] + + shells.forEach((shell) => { + const fireworks = new Fireworks({ + color: shell.color, + burstHeight: shell.height, + burstScale: shell.scale, + burstPositionOffset: new THREE.Vector3(...shell.offset), + launchDuration: shell.launchDuration, + burstDuration: shell.burstDuration, + loop: true + }) + scene.add(fireworks) + fireworksRef.current.push(fireworks) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + fireworksRef.current.forEach((fireworks) => fireworks.dispose()) + fireworksRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + +## Options + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| radius | number | 1 | Burst trail target length radius, baked into the geometry | +| detail | number | 0 | Icosahedron subdivision level, baked into the geometry | +| segments | number | 5 | Number of segments sampled along each trail, baked into the geometry | +| gravity | number | 1 | Gravity parabola coefficient that bends the trails downward, baked into the geometry | +| width | number | 0.1 | Ribbon half-width | +| startTime | number | 0 | Animation start time in seconds | +| launchDuration | number | 1.5 | Launch phase duration in seconds | +| delayDuration | number | -0.4 | Delay between launch and burst, may be negative to overlap | +| burstDuration | number | 2 | Burst phase duration in seconds | +| burstHeight | number | 6.5 | Burst height along the local Y axis | +| burstScale | number | 3.5 | Overall burst scale | +| burstPositionOffset | THREE.Vector3 | (0, 0, 0) | Burst origin offset | +| loop | boolean | true | Replay the effect when one cycle finishes | +| color | THREE.ColorRepresentation | '#ffd4a0' | Base ribbon color | +| bloomIntensity | number | 9 | Head glow intensity | +| bloomFalloff | number | 18 | Head glow length exponent, larger tightens the tip | +| noiseFalloff | number | 3 | Noise dissolve time exponent | +| noiseScale | THREE.Vector2 | (10, 200) | Noise UV scale (across, along) | + +## Methods + +| Name | Parameters | Description | +|------|------------|-------------| +| constructor | (options?: FireworksOptions) | Create a fireworks shell. It extends `THREE.Mesh`, so add it to the scene and move it with `position` | +| update | (deltaSeconds: number) => void | Advance the animation time in seconds | +| restart | () => void | Reset the current time to 0 and replay from `startTime` | +| dispose | () => void | Dispose the shell, stop the animation and remove it from the scene. Should be called on component unmount to prevent memory leaks | diff --git a/docs/guide/effects/height-fog.md b/docs/guide/effects/height-fog.md new file mode 100644 index 0000000..c1180af --- /dev/null +++ b/docs/guide/effects/height-fog.md @@ -0,0 +1,82 @@ +--- +lang: en-US +title: HeightFog +--- + +## Type + +Component + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import HeightFog from '@site/src/components/effects/HeightFog' + +## Default Usage + +Screen-space height fog: an exponential fog layer whose density falls off with world height, with flowing noise on the fog plane and directional inscattering towards a light source. Every example below can be viewed with either the **WebGPU** (default) or the **WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import { Scene, HeightFog } from 'react-three-lite' + + export default function App() { + return ( + + + + ) + } + ``` + + + + + ```tsx + import { Scene, HeightFog } from 'react-three-lite' + + export default function App() { + return ( + + + + ) + } + ``` + + + +## Props + +### Fog body + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| fogColor | string \| number \| Color | '#c9d4cc' | Fog color | +| fogDensity | number | 0.02 | Fog density at the fog plane | +| fogHeight | number | 10 | World height of the fog plane | +| fogHeightFalloff | number | 0.25 | Height falloff; density halves every 1/falloff units above the fog plane | +| fogMaxOpacity | number | 1 | Maximum fog opacity (caps how much the fog can cover the scene) | +| fogStartDistance | number | 0 | Distance from the camera where fog accumulation starts | + +### Flowing noise + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| noiseStrength | number | 0 | How far noise displaces the fog plane vertically (0 disables noise) | +| noiseScale | number | 0.012 | Scale of the noise pattern in world units | +| noiseSpeedX | number | 0 | Noise flow speed along the X axis | +| noiseSpeedZ | number | 0 | Noise flow speed along the Z axis | + +### Directional inscattering + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| inscatteringColor | string \| number \| Color | '#ffe2b0' | Color of the light inscattering | +| inscatteringIntensity | number | 1 | Inscattering intensity | +| inscatteringExponent | number | 5 | Sharpness of the directional falloff around the light direction | +| inscatteringStartDistance | number | 0 | Distance from the camera where inscattering starts | +| lightAzimuth | number | 30 | Light direction azimuth in degrees | +| lightElevation | number | 25 | Light direction elevation in degrees | diff --git a/docs/guide/picking.md b/docs/guide/picking.md index 7f9ff74..1051043 100644 --- a/docs/guide/picking.md +++ b/docs/guide/picking.md @@ -1,9 +1,8 @@ -*** - +--- id: picking lang: en-US title: Picking --------------- +--- import Tabs from '@theme/Tabs' import TabItem from '@theme/TabItem' diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json index bad83ce..bf707c2 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json @@ -99,6 +99,10 @@ "message": "下雪效果", "description": "The label for doc 'effects/snow' in sidebar 'guideSidebar'" }, + "sidebar.guideSidebar.doc.effects/height-fog": { + "message": "高度雾", + "description": "The label for doc 'effects/height-fog' in sidebar 'guideSidebar'" + }, "sidebar.guideSidebar.doc.effects/sweep-light": { "message": "扫光效果", "description": "The label for doc 'effects/sweep-light' in sidebar 'guideSidebar'" @@ -110,5 +114,9 @@ "sidebar.guideSidebar.doc.effects/light-pillar": { "message": "光柱效果", "description": "The label for doc 'effects/light-pillar' in sidebar 'guideSidebar'" + }, + "sidebar.guideSidebar.doc.effects/fireworks": { + "message": "烟花效果", + "description": "The label for doc 'effects/fireworks' in sidebar 'guideSidebar'" } } diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/fireworks.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/fireworks.md new file mode 100644 index 0000000..88227a6 --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/fireworks.md @@ -0,0 +1,170 @@ +--- +lang: zh-CN +title: 烟花效果 +--- + +## 类型 + +类 + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import Fireworks from '@site/src/components/effects/Fireworks' + +完全由着色器实现的烟花弹。一条发射拖尾从原点升起,随后炸裂成球状喷洒的、受重力弯曲的丝带轨迹:头部发光,尾部通过数值噪声溶解消散。一个 `Fireworks` 实例就是一发烟花 —— 添加多个不同颜色、高度与相位的实例即可编排一整场表演。 + +## 默认用法 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene, Fireworks } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const fireworksRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 6, 17) + camera.lookAt(0, 6, 0) + + const shells = [ + { color: 0xffd4a0, height: 6.6, scale: 3.6, offset: [-3.6, 0, -1.5], launchDuration: 1.25, burstDuration: 1.9 }, + { color: 0x7ad7ff, height: 5.9, scale: 3.9, offset: [-1.4, 0, 1.6], launchDuration: 1.7, burstDuration: 1.6 }, + { color: 0xff6b6b, height: 7.4, scale: 3.2, offset: [3.2, 0, -0.8], launchDuration: 1.5, burstDuration: 2.2 } + ] + + shells.forEach((shell) => { + const fireworks = new Fireworks({ + color: shell.color, + burstHeight: shell.height, + burstScale: shell.scale, + burstPositionOffset: new THREE.Vector3(...shell.offset), + launchDuration: shell.launchDuration, + burstDuration: shell.burstDuration, + loop: true + }) + scene.add(fireworks) + fireworksRef.current.push(fireworks) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + fireworksRef.current.forEach((fireworks) => fireworks.dispose()) + fireworksRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + + + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' + import { Scene, Fireworks } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const fireworksRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 6, 17) + camera.lookAt(0, 6, 0) + + const shells = [ + { color: 0xffd4a0, height: 6.6, scale: 3.6, offset: [-3.6, 0, -1.5], launchDuration: 1.25, burstDuration: 1.9 }, + { color: 0x7ad7ff, height: 5.9, scale: 3.9, offset: [-1.4, 0, 1.6], launchDuration: 1.7, burstDuration: 1.6 }, + { color: 0xff6b6b, height: 7.4, scale: 3.2, offset: [3.2, 0, -0.8], launchDuration: 1.5, burstDuration: 2.2 } + ] + + shells.forEach((shell) => { + const fireworks = new Fireworks({ + color: shell.color, + burstHeight: shell.height, + burstScale: shell.scale, + burstPositionOffset: new THREE.Vector3(...shell.offset), + launchDuration: shell.launchDuration, + burstDuration: shell.burstDuration, + loop: true + }) + scene.add(fireworks) + fireworksRef.current.push(fireworks) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + fireworksRef.current.forEach((fireworks) => fireworks.dispose()) + fireworksRef.current = [] + } + }, []) + + return ( + + ) + } + ``` + + + +## 配置项 + +| 属性 | 类型 | 默认值 | 描述 | +|------|------|--------|------| +| radius | number | 1 | 炸裂拖尾的目标长度半径,烘焙进几何体 | +| detail | number | 0 | 二十面体细分级别,烘焙进几何体 | +| segments | number | 5 | 每条拖尾上采样的分段数,烘焙进几何体 | +| gravity | number | 1 | 使拖尾向下弯曲的重力抛物线系数,烘焙进几何体 | +| width | number | 0.1 | 丝带半宽 | +| startTime | number | 0 | 动画起始时间(秒) | +| launchDuration | number | 1.5 | 发射阶段时长(秒) | +| delayDuration | number | -0.4 | 发射与炸裂之间的延迟,可为负值以重叠 | +| burstDuration | number | 2 | 炸裂阶段时长(秒) | +| burstHeight | number | 6.5 | 沿局部 Y 轴的炸裂高度 | +| burstScale | number | 3.5 | 整体炸裂缩放 | +| burstPositionOffset | THREE.Vector3 | (0, 0, 0) | 炸裂原点偏移 | +| loop | boolean | true | 一个周期结束后是否重新播放 | +| color | THREE.ColorRepresentation | '#ffd4a0' | 丝带基础颜色 | +| bloomIntensity | number | 9 | 头部光晕强度 | +| bloomFalloff | number | 18 | 头部光晕长度指数,越大头部越收窄 | +| noiseFalloff | number | 3 | 噪声溶解的时间指数 | +| noiseScale | THREE.Vector2 | (10, 200) | 噪声 UV 缩放(横向、纵向) | + +## 方法 + +| 名称 | 参数 | 描述 | +|------|------|------| +| constructor | (options?: FireworksOptions) | 创建一发烟花。它继承自 `THREE.Mesh`,直接加入场景并通过 `position` 摆放 | +| update | (deltaSeconds: number) => void | 按秒推进动画时间 | +| restart | () => void | 将当前时间重置为 0,并从 `startTime` 重新播放 | +| dispose | () => void | 销毁烟花、停止动画并从场景中移除。组件卸载时应调用以避免内存泄漏 | diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md new file mode 100644 index 0000000..acf8483 --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md @@ -0,0 +1,81 @@ +--- +title: 高度雾 +--- + +## 类型 + +组件 + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import HeightFogComponent from '@site/src/components/effects/HeightFog' + +## 默认用法 + +屏幕空间高度雾:密度随世界高度指数衰减的雾层,支持雾面流动噪声与朝向光源的方向散射。下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import { Scene, HeightFog } from 'react-three-lite' + + export default function App() { + return ( + + + + ) + } + ``` + + + + + ```tsx + import { Scene, HeightFog } from 'react-three-lite' + + export default function App() { + return ( + + + + ) + } + ``` + + + +## 属性 + +### 雾体 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| fogColor | string \| number \| Color | '#c9d4cc' | 雾颜色 | +| fogDensity | number | 0.02 | 雾面处的雾密度 | +| fogHeight | number | 10 | 雾面的世界高度 | +| fogHeightFalloff | number | 0.25 | 高度衰减系数;雾面之上每升高 1/falloff 个单位密度减半 | +| fogMaxOpacity | number | 1 | 雾的最大不透明度(限制雾对场景的遮盖程度) | +| fogStartDistance | number | 0 | 雾从距离相机多远开始累积 | + +### 流动噪声 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| noiseStrength | number | 0 | 噪声对雾面的垂直扰动幅度(0 表示关闭噪声) | +| noiseScale | number | 0.012 | 噪声图案的世界空间缩放 | +| noiseSpeedX | number | 0 | 噪声沿 X 轴的流动速度 | +| noiseSpeedZ | number | 0 | 噪声沿 Z 轴的流动速度 | + +### 方向散射 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| inscatteringColor | string \| number \| Color | '#ffe2b0' | 散射光颜色 | +| inscatteringIntensity | number | 1 | 散射强度 | +| inscatteringExponent | number | 5 | 方向衰减指数,值越大散射越集中在光源方向 | +| inscatteringStartDistance | number | 0 | 散射从距离相机多远开始 | +| lightAzimuth | number | 30 | 光源方向方位角(度) | +| lightElevation | number | 25 | 光源方向仰角(度) | diff --git a/docs/sidebars.js b/docs/sidebars.js index b91c471..cc18eac 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -27,7 +27,7 @@ const sidebars = { type: 'category', label: 'Effect', collapsible: true, - items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/sweep-light', 'effects/light-gradient', 'effects/light-pillar'], + items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/height-fog', 'effects/sweep-light', 'effects/light-gradient', 'effects/light-pillar', 'effects/fireworks'], }, { type: 'category', diff --git a/docs/src/components/effects/Fireworks.tsx b/docs/src/components/effects/Fireworks.tsx new file mode 100644 index 0000000..be6907b --- /dev/null +++ b/docs/src/components/effects/Fireworks.tsx @@ -0,0 +1,71 @@ +import { useRef, useEffect } from 'react' +import * as THREE from 'three' +import { Scene, Fireworks } from 'react-three-lite' +import type { SceneComponents } from 'react-three-lite' + +interface FireworksProps { + rendererType?: 'webgpu' | 'webgl' +} + +interface ShellConfig { + color: number + height: number + scale: number + offset: [number, number, number] + launchDuration: number + burstDuration: number +} + +// A small scripted show: each shell differs in color, burst height, spread, +// screen position and phase lengths so the shells desynchronise and keep +// popping instead of firing in a single synchronized volley. +const shells: ShellConfig[] = [ + { color: 0xffd4a0, height: 6.6, scale: 3.6, offset: [-3.6, 0, -1.5], launchDuration: 1.25, burstDuration: 1.9 }, + { color: 0xff6b6b, height: 7.4, scale: 3.2, offset: [3.2, 0, -0.8], launchDuration: 1.5, burstDuration: 2.2 }, + { color: 0x7ad7ff, height: 5.9, scale: 3.9, offset: [-1.4, 0, 1.6], launchDuration: 1.7, burstDuration: 1.6 }, + { color: 0xffe14d, height: 8.1, scale: 3.1, offset: [1.8, 0, -2.4], launchDuration: 1.35, burstDuration: 2.4 }, + { color: 0xb98bff, height: 6.9, scale: 3.5, offset: [-4.4, 0, 1.2], launchDuration: 1.6, burstDuration: 1.8 }, + { color: 0x66ffb2, height: 7.7, scale: 3.3, offset: [4.1, 0, 1.8], launchDuration: 1.2, burstDuration: 2.1 }, + { color: 0xffffff, height: 6.2, scale: 3.8, offset: [0.2, 0, 0.4], launchDuration: 1.45, burstDuration: 1.7 }, + { color: 0xff8ad4, height: 8.4, scale: 3.0, offset: [-2.6, 0, -2.8], launchDuration: 1.75, burstDuration: 2.3 }, + { color: 0xffa54d, height: 7.1, scale: 3.4, offset: [2.7, 0, 2.6], launchDuration: 1.3, burstDuration: 1.95 }, + { color: 0x8ab4ff, height: 6.8, scale: 3.7, offset: [-0.9, 0, -1.1], launchDuration: 1.55, burstDuration: 2.05 } +] + +export default function FireworksComponent({ rendererType }: FireworksProps) { + const fireworksRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + if (!camera) return + + camera.position.set(0, 6, 17) + camera.lookAt(0, 6, 0) + + shells.forEach((shell) => { + const fireworks = new Fireworks({ + color: shell.color, + burstHeight: shell.height, + burstScale: shell.scale, + burstPositionOffset: new THREE.Vector3(...shell.offset), + launchDuration: shell.launchDuration, + burstDuration: shell.burstDuration, + loop: true + }) + scene.add(fireworks) + fireworksRef.current.push(fireworks) + }) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + fireworksRef.current.forEach((fireworks) => fireworks.dispose()) + fireworksRef.current = [] + } + }, []) + + return ( + + ) +} diff --git a/docs/src/components/effects/HeightFog.tsx b/docs/src/components/effects/HeightFog.tsx new file mode 100644 index 0000000..a2cab87 --- /dev/null +++ b/docs/src/components/effects/HeightFog.tsx @@ -0,0 +1,13 @@ +import { Scene, HeightFog } from 'react-three-lite' + +interface HeightFogProps { + rendererType?: 'webgpu' | 'webgl' +} + +export default function HeightFogComponent({ rendererType }: HeightFogProps) { + return ( + + + + ) +} diff --git a/src/__tests__/Datum.test.ts b/src/__tests__/Datum.test.ts index d15baae..6da8ad7 100644 --- a/src/__tests__/Datum.test.ts +++ b/src/__tests__/Datum.test.ts @@ -1,11 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - wgs84ToGcj02, - gcj02ToWgs84, - gcj02ToBd09, - bd09ToGcj02, - convertDatum -} from '../crs/Datum' +import { wgs84ToGcj02, gcj02ToWgs84, gcj02ToBd09, bd09ToGcj02, convertDatum } from '../crs/Datum' const BEIJING = { lng: 116.404, lat: 39.915 } diff --git a/src/__tests__/Fireworks.test.ts b/src/__tests__/Fireworks.test.ts new file mode 100644 index 0000000..5eed198 --- /dev/null +++ b/src/__tests__/Fireworks.test.ts @@ -0,0 +1,191 @@ +import * as THREE from 'three' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Fireworks from '../utils/Fireworks' +import { installFakeRaf, type FakeRaf } from './helpers/fakeRaf' + +// The uniforms, the node material and the `added` listener are private +// implementation details, but they are exactly what the resource cleanup +// contract touches, so the tests read them through a narrow structural cast +// instead of widening the public API. +interface FireworksInternals { + animationId: number | null + uniforms: { + uTime: { value: number } + uLaunchDuration: { value: number } + uDelayDuration: { value: number } + uBurstDuration: { value: number } + uColor: { value: THREE.Color } + } + nodeMaterial: THREE.Material + onAdded: () => void +} + +function internals(fireworks: Fireworks): FireworksInternals { + return fireworks as unknown as FireworksInternals +} + +function cycleDuration(fireworks: Fireworks): number { + const { uLaunchDuration, uDelayDuration, uBurstDuration } = internals(fireworks).uniforms + return uLaunchDuration.value + uDelayDuration.value + uBurstDuration.value +} + +describe('Fireworks', () => { + let raf: FakeRaf + + beforeEach(() => { + raf = installFakeRaf() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('is a single mesh carrying the trail geometry', () => { + const fireworks = new Fireworks() + + expect(fireworks).toBeInstanceOf(THREE.Mesh) + expect(fireworks.geometry.getAttribute('position')).toBeDefined() + expect(fireworks.geometry.getAttribute('tangent')).toBeDefined() + expect(fireworks.geometry.getAttribute('uv')).toBeDefined() + expect(fireworks.geometry.getAttribute('isBurstTrail')).toBeDefined() + + fireworks.dispose() + }) + + it('starts a frame loop from the constructor', () => { + const fireworks = new Fireworks() + + expect(raf.request).toHaveBeenCalledTimes(1) + + fireworks.dispose() + }) + + it('advances the time uniform on update', () => { + const fireworks = new Fireworks() + + fireworks.update(0.5) + + expect(internals(fireworks).uniforms.uTime.value).toBeCloseTo(0.5) + + fireworks.dispose() + }) + + it('resets the time to 0 when a cycle finishes and loop is on', () => { + const fireworks = new Fireworks() + const duration = cycleDuration(fireworks) + + fireworks.update(duration + 0.5) + fireworks.update(0.1) + + expect(internals(fireworks).uniforms.uTime.value).toBe(0) + + fireworks.dispose() + }) + + it('keeps accumulating time when loop is off', () => { + const fireworks = new Fireworks({ loop: false }) + const duration = cycleDuration(fireworks) + + fireworks.update(duration + 0.5) + fireworks.update(0.1) + + expect(internals(fireworks).uniforms.uTime.value).toBeCloseTo(duration + 0.6) + + fireworks.dispose() + }) + + it('restarts the current time', () => { + const fireworks = new Fireworks() + + fireworks.update(1) + fireworks.restart() + + expect(internals(fireworks).uniforms.uTime.value).toBe(0) + + fireworks.dispose() + }) + + it('stops the frame loop on dispose', () => { + const fireworks = new Fireworks() + + fireworks.dispose() + + expect(raf.cancel).toHaveBeenCalledTimes(1) + expect(raf.pending.size).toBe(0) + expect(internals(fireworks).animationId).toBeNull() + + const requestCount = raf.request.mock.calls.length + raf.flush() + expect(raf.request.mock.calls.length).toBe(requestCount) + }) + + it('removes the added listener on dispose so the mesh can be garbage collected', () => { + const fireworks = new Fireworks() + const { onAdded } = internals(fireworks) + expect(fireworks.hasEventListener('added', onAdded)).toBe(true) + + fireworks.dispose() + + expect(fireworks.hasEventListener('added', onAdded)).toBe(false) + }) + + it('releases the geometry and material on dispose', () => { + const fireworks = new Fireworks() + const geometrySpy = vi.spyOn(fireworks.geometry, 'dispose') + const materialSpy = vi.spyOn(internals(fireworks).nodeMaterial, 'dispose') + + fireworks.dispose() + + expect(geometrySpy).toHaveBeenCalledTimes(1) + expect(materialSpy).toHaveBeenCalledTimes(1) + }) + + it('detaches itself from the scene on dispose', () => { + const scene = new THREE.Scene() + const fireworks = new Fireworks() + scene.add(fireworks) + + fireworks.dispose() + + expect(scene.children).not.toContain(fireworks) + }) + + it('is safe to dispose twice', () => { + const fireworks = new Fireworks() + + fireworks.dispose() + expect(() => fireworks.dispose()).not.toThrow() + expect(raf.cancel).toHaveBeenCalledTimes(1) + }) + + it('keeps raw colors when the scene renders through WebGL', () => { + const scene = new THREE.Scene() + scene.userData.renderer = { isWebGPURenderer: false } + const fireworks = new Fireworks({ color: 0x336699 }) + + scene.add(fireworks) + + expect(internals(fireworks).uniforms.uColor.value.getHex()).toBe( + new THREE.Color(0x336699).getHex() + ) + + fireworks.dispose() + }) + + it('pre-corrects colors when the scene renders through WebGPU', () => { + const scene = new THREE.Scene() + scene.userData.renderer = { isWebGPURenderer: true } + const fireworks = new Fireworks({ color: 0x336699 }) + + scene.add(fireworks) + + const brightness = 0.86 + const { r, g, b } = new THREE.Color(0x336699) + const color = internals(fireworks).uniforms.uColor.value + expect(color.r).toBeCloseTo(Math.pow(r, 2.2) * brightness) + expect(color.g).toBeCloseTo(Math.pow(g, 2.2) * brightness) + expect(color.b).toBeCloseTo(Math.pow(b, 2.2) * brightness) + + fireworks.dispose() + }) +}) diff --git a/src/__tests__/FlowLineMesh.test.ts b/src/__tests__/FlowLineMesh.test.ts index 273ed0a..fb3a986 100644 --- a/src/__tests__/FlowLineMesh.test.ts +++ b/src/__tests__/FlowLineMesh.test.ts @@ -118,7 +118,10 @@ describe('FlowLineMesh', () => { it('keeps raw colors when the scene renders through WebGL', () => { const scene = new THREE.Scene() scene.userData.renderer = { isWebGPURenderer: false } - const mesh = new FlowLineMesh({ color: [0.5, 0.25, 0.125, 0.5], arrowColor: [0.5, 0.25, 0.125] }) + const mesh = new FlowLineMesh({ + color: [0.5, 0.25, 0.125, 0.5], + arrowColor: [0.5, 0.25, 0.125] + }) scene.add(mesh) @@ -133,7 +136,10 @@ describe('FlowLineMesh', () => { it('pre-corrects line and arrow colors when the scene renders through WebGPU', () => { const scene = new THREE.Scene() scene.userData.renderer = { isWebGPURenderer: true } - const mesh = new FlowLineMesh({ color: [0.5, 0.25, 0.125, 0.5], arrowColor: [0.5, 0.25, 0.125] }) + const mesh = new FlowLineMesh({ + color: [0.5, 0.25, 0.125, 0.5], + arrowColor: [0.5, 0.25, 0.125] + }) scene.add(mesh) diff --git a/src/__tests__/GeoReference.test.ts b/src/__tests__/GeoReference.test.ts index 405ca25..4be8518 100644 --- a/src/__tests__/GeoReference.test.ts +++ b/src/__tests__/GeoReference.test.ts @@ -95,9 +95,7 @@ describe('GeoReference custom axis mapping', () => { const up = geo.toLocal({ ...BEIJING, alt: 10 }) expect(up.z).toBeCloseTo(10, 6) - expect( - () => new GeoReference(BEIJING, { axes: { east: 'x', north: 'x', up: 'z' } }) - ).toThrow() + expect(() => new GeoReference(BEIJING, { axes: { east: 'x', north: 'x', up: 'z' } })).toThrow() }) }) diff --git a/src/__tests__/LightPillar.test.ts b/src/__tests__/LightPillar.test.ts index d9589d2..8781bab 100644 --- a/src/__tests__/LightPillar.test.ts +++ b/src/__tests__/LightPillar.test.ts @@ -9,7 +9,11 @@ import { installFakeRaf, type FakeRaf } from './helpers/fakeRaf' // instead of widening the public API. interface LightPillarInternals { animationId: number | null - bundles: { timeUniform: { value: number }; colorUniform: { value: THREE.Color }; material: THREE.Material }[] + bundles: { + timeUniform: { value: number } + colorUniform: { value: THREE.Color } + material: THREE.Material + }[] geometries: THREE.BufferGeometry[] onAdded: () => void } @@ -117,8 +121,12 @@ describe('LightPillar', () => { it('releases every geometry and material on dispose', () => { const pillar = new LightPillar() - const geometrySpies = internals(pillar).geometries.map((geometry) => vi.spyOn(geometry, 'dispose')) - const materialSpies = internals(pillar).bundles.map((bundle) => vi.spyOn(bundle.material, 'dispose')) + const geometrySpies = internals(pillar).geometries.map((geometry) => + vi.spyOn(geometry, 'dispose') + ) + const materialSpies = internals(pillar).bundles.map((bundle) => + vi.spyOn(bundle.material, 'dispose') + ) pillar.dispose() @@ -184,7 +192,9 @@ describe('LightPillar', () => { scene.add(pillar) - expect(internals(pillar).bundles[0].colorUniform.value.getHex()).toBe(new THREE.Color(0x336699).getHex()) + expect(internals(pillar).bundles[0].colorUniform.value.getHex()).toBe( + new THREE.Color(0x336699).getHex() + ) pillar.dispose() }) diff --git a/src/__tests__/TileLayer.integration.test.ts b/src/__tests__/TileLayer.integration.test.ts index 0fad257..317d73c 100644 --- a/src/__tests__/TileLayer.integration.test.ts +++ b/src/__tests__/TileLayer.integration.test.ts @@ -30,16 +30,16 @@ describe('TileLayer request pipeline (integration)', () => { beforeEach(() => { requests = [] afterFrames = [] - loadSpy = vi.spyOn(THREE.TextureLoader.prototype, 'load').mockImplementation( - (url, onLoad, _onProgress, onError) => { + loadSpy = vi + .spyOn(THREE.TextureLoader.prototype, 'load') + .mockImplementation((url, onLoad, _onProgress, onError) => { requests.push({ url, onLoad: onLoad as CapturedRequest['onLoad'], onError: onError as CapturedRequest['onError'] }) return new THREE.Texture() - } - ) + }) }) afterEach(() => { @@ -84,7 +84,7 @@ describe('TileLayer request pipeline (integration)', () => { geo, renderer: { domElement: { clientHeight: 300 } } as unknown as SceneSlotProps['renderer'], addAfterFrame: (cb) => { - afterFrames.push(cb as typeof afterFrames[number]) + afterFrames.push(cb as (typeof afterFrames)[number]) return () => {} } } diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index e6fabac..a825f54 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -12,12 +12,14 @@ const EXPECTED_EXPORTS = [ 'Callout', 'FBXLoader', 'FBXLoaderAsync', + 'Fireworks', 'FlowLineMesh', 'GLTFLoader', 'GLTFLoaderAsync', 'GeoJsonLayer', 'GeoObject', 'GeoReference', + 'HeightFog', 'LightGradient', 'LightPillar', 'ModelRotator', @@ -94,8 +96,10 @@ describe('public API surface', () => { 'GeoObject', 'GeoJsonLayer', 'GeoReference', + 'HeightFog', 'LightGradient', 'LightPillar', + 'Fireworks', 'ModelRotator', 'Movable', 'OBJLoader', diff --git a/src/components/FBXLoader.tsx b/src/components/FBXLoader.tsx index 9703e25..8ca5a19 100644 --- a/src/components/FBXLoader.tsx +++ b/src/components/FBXLoader.tsx @@ -35,9 +35,7 @@ const FBXLoader = ({ const scene = propScene || sceneContext?.scene if (!scene) return - const model = await FBXLoaderUtil(modelUrl, cache, (event) => - onProgress?.(event) - ) + const model = await FBXLoaderUtil(modelUrl, cache, (event) => onProgress?.(event)) if (cancelled) return model.scale.set(...scale) diff --git a/src/components/GLTFLoader.tsx b/src/components/GLTFLoader.tsx index d7f8afb..896b1e9 100644 --- a/src/components/GLTFLoader.tsx +++ b/src/components/GLTFLoader.tsx @@ -34,7 +34,7 @@ const GLTFLoader = ({ useEffect(() => { let cancelled = false - + const init = async () => { const scene = propScene || sceneContext?.scene if (!scene) { @@ -47,7 +47,7 @@ const GLTFLoader = ({ onProgress?.(event) ) if (cancelled) return - + model.scale.set(...scale) scene.add(model) modelRef.current = model @@ -64,7 +64,17 @@ const GLTFLoader = ({ modelRef.current = null } } - }, [propScene, sceneContext?.scene, modelUrl, scale, cache, dracoDecoderPath, useDraco, onProgress, onLoaded]) + }, [ + propScene, + sceneContext?.scene, + modelUrl, + scale, + cache, + dracoDecoderPath, + useDraco, + onProgress, + onLoaded + ]) return null } diff --git a/src/components/GeoJsonLayer.tsx b/src/components/GeoJsonLayer.tsx index 1471f84..f429426 100644 --- a/src/components/GeoJsonLayer.tsx +++ b/src/components/GeoJsonLayer.tsx @@ -41,7 +41,10 @@ const DEFAULT_STYLE: Required> & { opacity: 0.85 } -function resolveStyle(base: GeoJsonStyle | undefined, override?: GeoJsonStyle | null): GeoJsonStyle { +function resolveStyle( + base: GeoJsonStyle | undefined, + override?: GeoJsonStyle | null +): GeoJsonStyle { return { ...DEFAULT_STYLE, ...(base ?? {}), ...(override ?? {}) } } @@ -90,7 +93,14 @@ function buildPolygonMesh( return new THREE.Mesh(geometry, material) } -const GeoJsonLayer = ({ data, datum, style, featureStyle, onReady, scene: propScene }: GeoJsonLayerProps) => { +const GeoJsonLayer = ({ + data, + datum, + style, + featureStyle, + onReady, + scene: propScene +}: GeoJsonLayerProps) => { const sceneContext = useScene() const groupRef = useRef(null) const effectiveDatum = datum ?? sceneContext?.geo?.datum @@ -164,11 +174,7 @@ const GeoJsonLayer = ({ data, datum, style, featureStyle, onReady, scene: propSc // extrusion heights stay possible. Features are flattened individually to // preserve the feature -> style mapping. const features = - data.type === 'FeatureCollection' - ? data.features - : data.type === 'Feature' - ? [data] - : null + data.type === 'FeatureCollection' ? data.features : data.type === 'Feature' ? [data] : null const featureGeometries: { rings: GeoJsonRingSet; override?: GeoJsonStyle | null }[] = [] if (features) { features.forEach((feature) => { diff --git a/src/components/HeightFog.tsx b/src/components/HeightFog.tsx new file mode 100644 index 0000000..0db5951 --- /dev/null +++ b/src/components/HeightFog.tsx @@ -0,0 +1,415 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { NodeMaterial, RenderPipeline, type Node } from 'three/webgpu' +import { + Fn, + uniform, + float, + vec3, + vec4, + texture, + screenUV, + pass, + positionLocal, + getViewPosition, + renderOutput, + mx_noise_float, + abs, + clamp, + dot, + exp2, + length, + max, + min, + normalize, + oneMinus, + pow, + saturate, + select +} from 'three/tsl' +import { useScene } from '../context/SceneContext' + +interface HeightFogProps { + fogColor?: string | number | THREE.Color + fogDensity?: number + fogHeight?: number + fogHeightFalloff?: number + fogMaxOpacity?: number + fogStartDistance?: number + noiseStrength?: number + noiseScale?: number + noiseSpeedX?: number + noiseSpeedZ?: number + inscatteringColor?: string | number | THREE.Color + inscatteringIntensity?: number + inscatteringExponent?: number + inscatteringStartDistance?: number + lightAzimuth?: number + lightElevation?: number + children?: React.ReactNode +} + +interface HeightFogNodeUniforms { + cameraPosition: Node<'vec3'> + cameraFar: Node<'float'> + cameraWorldMatrix: Node<'mat4'> + cameraProjectionMatrixInverse: Node<'mat4'> + fogColor: Node<'vec3'> + fogDensity: Node<'float'> + fogHeight: Node<'float'> + fogHeightFalloff: Node<'float'> + fogMaxOpacity: Node<'float'> + fogStartDistance: Node<'float'> + noiseStrength: Node<'float'> + noiseScale: Node<'float'> + noiseSpeedX: Node<'float'> + noiseSpeedZ: Node<'float'> + noiseTime: Node<'float'> + inscatteringColor: Node<'vec3'> + inscatteringIntensity: Node<'float'> + inscatteringExponent: Node<'float'> + inscatteringStartDistance: Node<'float'> + lightDirection: Node<'vec3'> +} + +const LN2 = 0.6931471805599453 + +/** + * Analytic optical depth of an exponential height fog layer integrated along a + * ray segment. The density at world height y is `density * 2^(falloff * (height + * - y))`, i.e. it halves every time y rises by 1/falloff above the fog plane. + * The closed-form line integral avoids ray marching. + */ +const opticalDepth = ( + density: Node<'float'>, + rayLength: Node<'float'>, + y0: Node<'float'>, + y1: Node<'float'>, + height: Node<'float'>, + falloff: Node<'float'> +): Node<'float'> => { + const e0 = falloff.mul(height.sub(y0)) + const e1 = falloff.mul(height.sub(y1)) + const x = abs(e0.sub(e1)) + // (1 - 2^-x) / (x * ln2) tends to 1 as x -> 0, so switch to the constant + // limit below a threshold instead of dividing by a near-zero x. + const lineIntegral = select( + x.lessThan(0.01), + float(1.0), + oneMinus(exp2(x.negate())).div(x.mul(LN2)) + ) + return density.mul(rayLength).mul(exp2(min(max(e0, e1), float(126.0)))).mul(lineIntegral) +} + +/** + * Shared TSL fog node used by both renderers. Reconstructs the world position + * of every fragment from the depth buffer, accumulates exponential height fog + * along the view ray and composites fog color, directional inscattering and + * the scene color in linear space. + * + * `sceneDepth` is intentionally untyped: depth texture nodes are declared as + * 'vec4' statically but evaluate to a single float at runtime, so the node is + * forwarded to `getViewPosition` untouched (no swizzle, no arithmetic). + */ +const buildFogNode = ( + sceneColor: Node<'vec4'>, + sceneDepth: Node, + u: HeightFogNodeUniforms +): Node<'vec4'> => { + const viewPosition = getViewPosition(screenUV, sceneDepth, u.cameraProjectionMatrixInverse) + const worldPosition = u.cameraWorldMatrix.mul(vec4(viewPosition, 1.0)).xyz + + const toFragment = worldPosition.sub(u.cameraPosition) + const rayLength = length(toFragment) + const rayDirection = normalize(toFragment) + + // Flowing noise displaces the fog plane. Sky fragments (nothing rendered, + // depth on the far plane) have no meaningful world position, so their noise + // is sampled where the view ray crosses the fog plane instead. + const isSky = rayLength.greaterThan(u.cameraFar.mul(0.95)) + const tToFogPlane = clamp( + select( + abs(rayDirection.y).greaterThan(0.001), + u.fogHeight.sub(u.cameraPosition.y).div(rayDirection.y), + float(0.0) + ), + float(0.0), + float(2.0).div(max(u.noiseScale, float(0.0001))) + ) + const skyNoisePosition = u.cameraPosition.add(rayDirection.mul(tToFogPlane)) + const noisePosition = select(isSky, skyNoisePosition, worldPosition) + const noiseOffset = vec3(u.noiseTime.mul(u.noiseSpeedX), float(0.0), u.noiseTime.mul(u.noiseSpeedZ)) + const noise = mx_noise_float(noisePosition.mul(u.noiseScale).add(noiseOffset)) + const noisyFogHeight = u.fogHeight.add(noise.mul(u.noiseStrength)) + + // Optical depth for the main fog. The first fogStartDistance units along + // the ray are excluded so fog does not build up directly at the camera. + const fogExcludedLength = clamp(u.fogStartDistance, float(0.0), rayLength) + const fogStartY = u.cameraPosition.y.add(rayDirection.y.mul(fogExcludedLength)) + const tau = opticalDepth( + u.fogDensity, + rayLength.sub(fogExcludedLength), + fogStartY, + worldPosition.y, + noisyFogHeight, + u.fogHeightFalloff + ) + const transmittance = max(saturate(exp2(tau.negate())), oneMinus(u.fogMaxOpacity)) + + // Directional inscattering towards the light, with its own start distance. + const cosAngle = saturate(dot(rayDirection, u.lightDirection)) + const dirExcludedLength = clamp(u.inscatteringStartDistance, float(0.0), rayLength) + const dirStartY = u.cameraPosition.y.add(rayDirection.y.mul(dirExcludedLength)) + const dirTau = opticalDepth( + u.fogDensity, + rayLength.sub(dirExcludedLength), + dirStartY, + worldPosition.y, + noisyFogHeight, + u.fogHeightFalloff + ) + const directionalInscattering = u.inscatteringColor + .mul(u.inscatteringIntensity) + .mul(pow(cosAngle, u.inscatteringExponent)) + .mul(oneMinus(saturate(exp2(dirTau.negate())))) + + const finalColor = u.fogColor + .mul(oneMinus(transmittance)) + .add(directionalInscattering) + .add(sceneColor.rgb.mul(transmittance)) + return vec4(finalColor, 1.0) +} + +/** + * Screen-space height fog post-processing effect. + * + * Uses two code paths depending on the active renderer: + * - **WebGPURenderer**: `pass()` + `RenderPipeline` from three/webgpu. The fog + * node outputs linear color and RenderPipeline applies the output transform. + * - **WebGLRenderer**: the scene is rendered into a half-float render target + * with a depth texture, then a fullscreen quad composites the fog and + * applies the linear -> sRGB output transform explicitly via `renderOutput`. + * + * Both paths share the same TSL fog node (`buildFogNode`). + */ +const HeightFog = ({ + fogColor = '#c9d4cc', + fogDensity = 0.02, + fogHeight = 10, + fogHeightFalloff = 0.25, + fogMaxOpacity = 1, + fogStartDistance = 0, + noiseStrength = 0, + noiseScale = 0.012, + noiseSpeedX = 0, + noiseSpeedZ = 0, + inscatteringColor = '#ffe2b0', + inscatteringIntensity = 1, + inscatteringExponent = 5, + inscatteringStartDistance = 0, + lightAzimuth = 30, + lightElevation = 25, + children +}: HeightFogProps) => { + const sceneContext = useScene() + const postProcessingRef = useRef(null) + const renderTargetRef = useRef(null) + const quadMaterialRef = useRef(null) + const quadGeometryRef = useRef(null) + + useEffect(() => { + const { renderer, scene, sceneComponents, container, setFrame } = sceneContext + + if (!renderer || !scene || !sceneComponents?.camera || !container) { + return + } + + const { camera } = sceneComponents + + // THREE.Color converts sRGB hex strings to the linear working color space + // on construction, which matches the linear space of the scene render + // target on both backends, so the raw components can be used directly. + const fogColorLinear = new THREE.Color(fogColor) + const inscatteringColorLinear = new THREE.Color(inscatteringColor) + + const azimuth = THREE.MathUtils.degToRad(lightAzimuth) + const elevation = THREE.MathUtils.degToRad(lightElevation) + const lightDirection = new THREE.Vector3( + Math.cos(elevation) * Math.sin(azimuth), + Math.sin(elevation), + Math.cos(elevation) * Math.cos(azimuth) + ).normalize() + + // Camera state is forwarded through custom uniforms because the fog runs + // in a post-processing pass whose active camera is an internal fullscreen + // camera, so the built-in camera TSL nodes do not describe the scene camera. + const uCameraPosition = uniform(new THREE.Vector3()) + const uCameraFar = uniform(camera.far) + const uCameraWorldMatrix = uniform(new THREE.Matrix4()) + const uCameraProjectionMatrixInverse = uniform(new THREE.Matrix4()) + + const uFogColor = uniform(new THREE.Vector3(fogColorLinear.r, fogColorLinear.g, fogColorLinear.b)) + const uFogDensity = uniform(fogDensity) + const uFogHeight = uniform(fogHeight) + const uFogHeightFalloff = uniform(fogHeightFalloff) + const uFogMaxOpacity = uniform(fogMaxOpacity) + const uFogStartDistance = uniform(fogStartDistance) + const uNoiseStrength = uniform(noiseStrength) + const uNoiseScale = uniform(noiseScale) + const uNoiseSpeedX = uniform(noiseSpeedX) + const uNoiseSpeedZ = uniform(noiseSpeedZ) + const uNoiseTime = uniform(0) + const uInscatteringColor = uniform( + new THREE.Vector3(inscatteringColorLinear.r, inscatteringColorLinear.g, inscatteringColorLinear.b) + ) + const uInscatteringIntensity = uniform(inscatteringIntensity) + const uInscatteringExponent = uniform(inscatteringExponent) + const uInscatteringStartDistance = uniform(inscatteringStartDistance) + const uLightDirection = uniform(lightDirection) + + const fogUniforms: HeightFogNodeUniforms = { + cameraPosition: uCameraPosition, + cameraFar: uCameraFar, + cameraWorldMatrix: uCameraWorldMatrix, + cameraProjectionMatrixInverse: uCameraProjectionMatrixInverse, + fogColor: uFogColor, + fogDensity: uFogDensity, + fogHeight: uFogHeight, + fogHeightFalloff: uFogHeightFalloff, + fogMaxOpacity: uFogMaxOpacity, + fogStartDistance: uFogStartDistance, + noiseStrength: uNoiseStrength, + noiseScale: uNoiseScale, + noiseSpeedX: uNoiseSpeedX, + noiseSpeedZ: uNoiseSpeedZ, + noiseTime: uNoiseTime, + inscatteringColor: uInscatteringColor, + inscatteringIntensity: uInscatteringIntensity, + inscatteringExponent: uInscatteringExponent, + inscatteringStartDistance: uInscatteringStartDistance, + lightDirection: uLightDirection + } + + const updateFrameUniforms = () => { + camera.getWorldPosition(uCameraPosition.value) + uCameraWorldMatrix.value.copy(camera.matrixWorld) + uCameraProjectionMatrixInverse.value.copy(camera.projectionMatrix).invert() + uCameraFar.value = camera.far + uNoiseTime.value += 0.016 + } + + let resizeObserver: ResizeObserver | undefined + + if (renderer.isWebGPURenderer === true) { + // --- WebGPU path: pass() + RenderPipeline --- + const scenePass = pass(scene, camera) + const scenePassColor = scenePass.getTextureNode('output') + const scenePassDepth = scenePass.getTextureNode('depth') + + const postProcessing = new RenderPipeline(renderer) + // Linear fog color: RenderPipeline applies the output color transform. + postProcessing.outputNode = buildFogNode(scenePassColor, scenePassDepth, fogUniforms) + postProcessingRef.current = postProcessing + + setFrame?.(() => { + updateFrameUniforms() + postProcessing.render() + }) + } else { + // --- WebGL path: scene -> render target, then fullscreen fog quad --- + const width = Math.max(1, container.clientWidth) + const height = Math.max(1, container.clientHeight) + const renderTarget = new THREE.WebGLRenderTarget(width, height, { + type: THREE.HalfFloatType + }) + renderTarget.depthTexture = new THREE.DepthTexture(width, height) + renderTargetRef.current = renderTarget + + const quadScene = new THREE.Scene() + const quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) + const quadGeometry = new THREE.PlaneGeometry(2, 2) + const quadMaterial = new NodeMaterial() + quadMaterial.depthTest = false + quadMaterial.depthWrite = false + quadMaterial.side = THREE.DoubleSide + quadMaterial.vertexNode = Fn(() => vec4(positionLocal.xy, 0.0, 1.0))() + + const sceneColorNode = texture(renderTarget.texture, screenUV) + const sceneDepthNode = texture(renderTarget.depthTexture, screenUV) + // Custom fragment nodes bypass the WebGLNodesHandler output transform, + // so the linear -> sRGB conversion is applied explicitly. + quadMaterial.fragmentNode = renderOutput( + buildFogNode(sceneColorNode, sceneDepthNode, fogUniforms), + THREE.NoToneMapping, + THREE.SRGBColorSpace + ) + + const quad = new THREE.Mesh(quadGeometry, quadMaterial) + quad.frustumCulled = false + quadScene.add(quad) + quadMaterialRef.current = quadMaterial + quadGeometryRef.current = quadGeometry + + // Keep the render target in sync with the container. The container can + // be 0-sized on first mount, so only resize once it has a real size. + resizeObserver = new ResizeObserver(() => { + const w = container.clientWidth + const h = container.clientHeight + if (w > 0 && h > 0) { + renderTarget.setSize(w, h) + } + }) + resizeObserver.observe(container) + + // Scene sets renderer.autoClear = false, so every target is cleared + // explicitly before drawing. + setFrame?.(() => { + updateFrameUniforms() + renderer.setRenderTarget(renderTarget) + renderer.clear() + renderer.render(scene, camera) + renderer.setRenderTarget(null) + renderer.clear() + renderer.render(quadScene, quadCamera) + }) + } + + // Cleanup on unmount + return () => { + resizeObserver?.disconnect() + postProcessingRef.current?.dispose() + postProcessingRef.current = null + if (renderTargetRef.current) { + renderTargetRef.current.depthTexture?.dispose() + renderTargetRef.current.dispose() + renderTargetRef.current = null + } + quadMaterialRef.current?.dispose() + quadMaterialRef.current = null + quadGeometryRef.current?.dispose() + quadGeometryRef.current = null + } + }, [ + sceneContext, + fogColor, + fogDensity, + fogHeight, + fogHeightFalloff, + fogMaxOpacity, + fogStartDistance, + noiseStrength, + noiseScale, + noiseSpeedX, + noiseSpeedZ, + inscatteringColor, + inscatteringIntensity, + inscatteringExponent, + inscatteringStartDistance, + lightAzimuth, + lightElevation + ]) + + return <>{children} +} + +export default HeightFog diff --git a/src/components/OBJLoader.tsx b/src/components/OBJLoader.tsx index 020279a..253cb18 100644 --- a/src/components/OBJLoader.tsx +++ b/src/components/OBJLoader.tsx @@ -37,9 +37,7 @@ const OBJLoader = ({ const scene = propScene || sceneContext?.scene if (!scene) return - const model = await OBJLoaderUtil(modelUrl, mtlUrl, cache, (event) => - onProgress?.(event) - ) + const model = await OBJLoaderUtil(modelUrl, mtlUrl, cache, (event) => onProgress?.(event)) if (cancelled) return model.scale.set(...scale) diff --git a/src/components/Rain.tsx b/src/components/Rain.tsx index 5b66d28..908c5c9 100644 --- a/src/components/Rain.tsx +++ b/src/components/Rain.tsx @@ -185,12 +185,7 @@ const Rain = ({ oneMinus(smoothstep(0.0, 0.05, t).mul(0.5)).sub(smoothstep(0.95, 1.0, t).mul(0.5)) ) - return vec4( - centerClip.x.add(offsetX), - centerClip.y.add(offsetY), - centerClip.z, - centerClip.w - ) + return vec4(centerClip.x.add(offsetX), centerClip.y.add(offsetY), centerClip.z, centerClip.w) })() // Fragment shader: rain drop shape using UV coordinates diff --git a/src/components/Snow.tsx b/src/components/Snow.tsx index ed936de..9a12e86 100644 --- a/src/components/Snow.tsx +++ b/src/components/Snow.tsx @@ -252,12 +252,7 @@ const Snow = ({ oneMinus(smoothstep(0.0, 0.05, t).mul(0.3)).sub(smoothstep(0.95, 1.0, t).mul(0.3)) ) - return vec4( - centerClip.x.add(offsetX), - centerClip.y.add(offsetY), - centerClip.z, - centerClip.w - ) + return vec4(centerClip.x.add(offsetX), centerClip.y.add(offsetY), centerClip.z, centerClip.w) })() // Fragment shader: sample snowflake texture using UV coordinates diff --git a/src/components/TileLayer.tsx b/src/components/TileLayer.tsx index 6870331..1deb172 100644 --- a/src/components/TileLayer.tsx +++ b/src/components/TileLayer.tsx @@ -257,11 +257,7 @@ function boundsCenter(bounds: GroundRect): { x: number; z: number } { * Compute a tile's local-space ground bounds, in metres, from its geographic * corners (without the render overlap). The result is cached on the node. */ -function computeBounds( - node: TileLayerNode, - geo: GeoReference, - datum: DatumType -): GroundRect { +function computeBounds(node: TileLayerNode, geo: GeoReference, datum: DatumType): GroundRect { if (node.bounds) return node.bounds const { nw, ne, se, sw } = tileToCorners(node.index) const pNW = geo.toLocal(nw, datum) @@ -564,9 +560,7 @@ const TileLayer = ({ ? Math.sqrt(Math.max(orbitDistance * orbitDistance - height * height, 0)) : 0 const rangeCap = height * MAX_GROUND_RANGE_FACTOR - const edgeDistance = Math.sqrt( - Math.max(rangeCap - horizontal, 0) ** 2 + height * height - ) + const edgeDistance = Math.sqrt(Math.max(rangeCap - horizontal, 0) ** 2 + height * height) layerFog.near = Math.max( edgeDistance * 0.55, orbitDistance > 0 ? orbitDistance * 1.1 : height * 2 @@ -671,8 +665,7 @@ const TileLayer = ({ nodes.forEach((node) => { if (node.rendering && !node.mesh) buildNodeMesh(node) if (node.mesh) { - node.mesh.visible = - node.rendering || underlayRefs.has(tileKey(node.index)) + node.mesh.visible = node.rendering || underlayRefs.has(tileKey(node.index)) } if (node.rendering) anyVisible = true if (!node.rendering) endFade(node) @@ -879,10 +872,7 @@ const TileLayer = ({ // (2 d tan(fov/2)) pixels; it needs refining once that exceeds the // threshold. The vertical gap to the plane counts toward d so a // top-down view uses its height, not a near-zero distance. - const screenSpaceError = ( - node: TileLayerNode, - camera: THREE.PerspectiveCamera - ): number => { + const screenSpaceError = (node: TileLayerNode, camera: THREE.PerspectiveCamera): number => { const bounds = computeBounds(node, geo, effectiveDatum) const center = boundsCenter(bounds) const groundWidth = Math.max(bounds.maxX - bounds.minX, bounds.maxZ - bounds.minZ) @@ -920,11 +910,7 @@ const TileLayer = ({ ) const centerGeo = geo.toGeo(footprintCenter, effectiveDatum) const leafCenterTile = lngLatToTile(centerGeo.lng, centerGeo.lat, zoom) - const leafBounds = computeBounds( - pyramid.getOrCreate(leafCenterTile), - geo, - effectiveDatum - ) + const leafBounds = computeBounds(pyramid.getOrCreate(leafCenterTile), geo, effectiveDatum) const leafGroundSize = Math.max( leafBounds.maxX - leafBounds.minX, leafBounds.maxZ - leafBounds.minZ, diff --git a/src/crs/Datum.ts b/src/crs/Datum.ts index f030c33..3b788a5 100644 --- a/src/crs/Datum.ts +++ b/src/crs/Datum.ts @@ -14,17 +14,19 @@ function isInChina(lng: number, lat: number): boolean { function transformLat(x: number, y: number): number { let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)) - ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0 - ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin((y / 3.0) * Math.PI)) * 2.0 / 3.0 - ret += (160.0 * Math.sin((y / 12.0) * Math.PI) + 320 * Math.sin((y * Math.PI) / 30.0)) * 2.0 / 3.0 + ret += ((20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0) / 3.0 + ret += ((20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin((y / 3.0) * Math.PI)) * 2.0) / 3.0 + ret += + ((160.0 * Math.sin((y / 12.0) * Math.PI) + 320 * Math.sin((y * Math.PI) / 30.0)) * 2.0) / 3.0 return ret } function transformLng(x: number, y: number): number { let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)) - ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0 - ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin((x / 3.0) * Math.PI)) * 2.0 / 3.0 - ret += (150.0 * Math.sin((x / 12.0) * Math.PI) + 300.0 * Math.sin((x / 30.0) * Math.PI)) * 2.0 / 3.0 + ret += ((20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0) / 3.0 + ret += ((20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin((x / 3.0) * Math.PI)) * 2.0) / 3.0 + ret += + ((150.0 * Math.sin((x / 12.0) * Math.PI) + 300.0 * Math.sin((x / 30.0) * Math.PI)) * 2.0) / 3.0 return ret } diff --git a/src/crs/GeoReference.ts b/src/crs/GeoReference.ts index 499df81..d8c69c7 100644 --- a/src/crs/GeoReference.ts +++ b/src/crs/GeoReference.ts @@ -21,7 +21,9 @@ function parseAxis(spec: string): AxisSpec { const negative = spec.trim().startsWith('-') const name = spec.trim().replace(/^[+-]/, '') if (name !== 'x' && name !== 'y' && name !== 'z') { - throw new Error(`GeoReference: invalid axis "${spec}", expected one of x/y/z with optional sign`) + throw new Error( + `GeoReference: invalid axis "${spec}", expected one of x/y/z with optional sign` + ) } return { axis: (name.charCodeAt(0) - 120) as 0 | 1 | 2, sign: negative ? -1 : 1 } } @@ -131,9 +133,6 @@ export class GeoReference { } /** Convenience factory mirroring the functional style of the other utilities. */ -export function createGeoReference( - origin?: GeoPoint, - options?: GeoReferenceOptions -): GeoReference { +export function createGeoReference(origin?: GeoPoint, options?: GeoReferenceOptions): GeoReference { return new GeoReference(origin, options) } diff --git a/src/crs/TilePyramid.ts b/src/crs/TilePyramid.ts index 28fab41..323bd7e 100644 --- a/src/crs/TilePyramid.ts +++ b/src/crs/TilePyramid.ts @@ -181,9 +181,7 @@ export class TilePyramid { if ( ideal && idealChildren.length > 0 && - idealChildren.every( - (child) => this.nodes.get(tileKey(child))?.state === TileState.Loaded - ) + idealChildren.every((child) => this.nodes.get(tileKey(child))?.state === TileState.Loaded) ) { idealChildren.forEach((child) => visit(child, true)) return diff --git a/src/index.ts b/src/index.ts index ba0f67e..067a2f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import OBJLoader from './components/OBJLoader' import Bloom from './components/Bloom' import Rain from './components/Rain' import Snow from './components/Snow' +import HeightFog from './components/HeightFog' import GeoObject from './components/GeoObject' import GeoJsonLayer from './components/GeoJsonLayer' import TileLayer from './components/TileLayer' @@ -28,6 +29,7 @@ import SweepLight from './utils/SweepLight' import ModelRotator from './utils/ModelRotator' import Picker from './utils/Picker' import LightPillar from './utils/LightPillar' +import Fireworks from './utils/Fireworks' import { fitCameraToBox, fitCameraToObject, @@ -41,6 +43,7 @@ import { LightGradient, LightGradientOptions } from './utils/Light' import type { CalloutOptions, LineShape, BendAxis, LabelAnchor } from './utils/Callout' import type { PickEvent, PickEventType, PickCallback, PickerOptions } from './utils/Picker' import type { LightPillarOptions } from './utils/LightPillar' +import type { FireworksOptions } from './utils/Fireworks' import type { CameraFitOptions } from './utils/CameraFit' // components @@ -52,6 +55,7 @@ export { Bloom, Rain, Snow, + HeightFog, GeoObject, GeoJsonLayer, TileLayer, @@ -124,6 +128,7 @@ export type { RendererType } from './utils/Renderer' export type { LightGradientOptions, CalloutOptions, LineShape, BendAxis, LabelAnchor } export type { PickEvent, PickEventType, PickCallback, PickerOptions } export type { LightPillarOptions } +export type { FireworksOptions } export type { CameraFitOptions } export type { FitCameraOptions } from './utils/FitCamera' @@ -141,6 +146,7 @@ export { ModelRotator, Picker, LightPillar, + Fireworks, FitCamera } diff --git a/src/meshes/FlowLineMesh.ts b/src/meshes/FlowLineMesh.ts index b0c4aeb..568d2a5 100644 --- a/src/meshes/FlowLineMesh.ts +++ b/src/meshes/FlowLineMesh.ts @@ -1,6 +1,21 @@ import * as THREE from 'three' import { NodeMaterial } from 'three/webgpu' -import { Fn, uniform, float, vec2, vec4, uv, texture, varying, abs, smoothstep, mix, max, fract, oneMinus } from 'three/tsl' +import { + Fn, + uniform, + float, + vec2, + vec4, + uv, + texture, + varying, + abs, + smoothstep, + mix, + max, + fract, + oneMinus +} from 'three/tsl' import { AxisType } from '../enums/AxisType' // V-shaped arrow drawing ported from the previous ShaderMaterial version @@ -112,11 +127,15 @@ function createLineGeometry( const offset = normal.clone().multiplyScalar(width / 2) positions.push( - point.x - offset.x, point.y - offset.y, point.z - offset.z, - point.x + offset.x, point.y + offset.y, point.z + offset.z + point.x - offset.x, + point.y - offset.y, + point.z - offset.z, + point.x + offset.x, + point.y + offset.y, + point.z + offset.z ) - const uvX = (lengths[i] / totalLength) / width + const uvX = lengths[i] / totalLength / width uvs.push(uvX, 0, uvX, 1) if (i < smoothPoints.length - 1) { @@ -190,7 +209,13 @@ function getLineMaterial( return vec4(finalColor, finalAlpha) })() - return { material, texture: arrowTexture, timeUniform: uTime, lineColorUniform: uLineColor, arrowColorUniform: uArrowColor } + return { + material, + texture: arrowTexture, + timeUniform: uTime, + lineColorUniform: uLineColor, + arrowColorUniform: uArrowColor + } } interface FlowLineMeshOptions { @@ -239,7 +264,11 @@ export default class FlowLineMesh extends THREE.Mesh { const speed = options.speed ?? 4.0 const { geometry } = createLineGeometry(points, width, axis) - const { material, texture, timeUniform, lineColorUniform, arrowColorUniform } = getLineMaterial(color, arrowColor, textureRepeat) + const { material, texture, timeUniform, lineColorUniform, arrowColorUniform } = getLineMaterial( + color, + arrowColor, + textureRepeat + ) super(geometry, material) this.timeUniform = timeUniform this.lineColorUniform = lineColorUniform @@ -315,4 +344,4 @@ export default class FlowLineMesh extends THREE.Mesh { this.arrowTexture?.dispose() this.arrowTexture = null } -} \ No newline at end of file +} diff --git a/src/meshes/WaveCircleMesh.ts b/src/meshes/WaveCircleMesh.ts index ab95d77..b3d776c 100644 --- a/src/meshes/WaveCircleMesh.ts +++ b/src/meshes/WaveCircleMesh.ts @@ -61,14 +61,18 @@ function getGeometry(axis: AxisType, radius: number): THREE.CircleGeometry { function getMaterial( radius?: number, color?: Array4 -): { material: NodeMaterial; timeUniform: ReturnType; colorUniform: { value: THREE.Vector4 } } { +): { + material: NodeMaterial + timeUniform: ReturnType + colorUniform: { value: THREE.Vector4 } +} { const uTime = uniform(0) const uRadius = uniform(radius ?? 1) const uCenter = uniform(new THREE.Vector3(0, 0, 0)) // Correct color taken from the WebGL screenshot [R,G,B,A]; pre-correction is // applied later based on the renderer type. - const origColor = color ?? [0.52, 0.78, 0.80, 1] + const origColor = color ?? [0.52, 0.78, 0.8, 1] const uColor = uniform(new THREE.Vector4(origColor[0], origColor[1], origColor[2], origColor[3])) const material = new NodeMaterial() @@ -124,7 +128,7 @@ export default class WaveCircleMesh extends THREE.Mesh { this.timeUniform = timeUniform this.colorUniform = colorUniform - this.origColor = (options.color ?? [0.52, 0.78, 0.80, 1]).slice() as Array4 + this.origColor = (options.color ?? [0.52, 0.78, 0.8, 1]).slice() as Array4 // Auto-detect the renderer from the scene the mesh belongs to: WebGPU // converts linear to sRGB on final output, so pre-correct the color so it diff --git a/src/utils/Animation.ts b/src/utils/Animation.ts index e4d40ae..e024116 100644 --- a/src/utils/Animation.ts +++ b/src/utils/Animation.ts @@ -1,4 +1,14 @@ -import { Mesh, Group, Object3D, AnimationMixer, AnimationClip, AnimationAction, LoopOnce, LoopRepeat, LoopPingPong } from 'three' +import { + Mesh, + Group, + Object3D, + AnimationMixer, + AnimationClip, + AnimationAction, + LoopOnce, + LoopRepeat, + LoopPingPong +} from 'three' export default class Animation { /** @@ -17,8 +27,7 @@ export default class Animation { * Animation loop type. * @defaultValue `LoopRepeat` */ - loop: typeof LoopOnce | typeof LoopRepeat | typeof LoopPingPong = - LoopRepeat + loop: typeof LoopOnce | typeof LoopRepeat | typeof LoopPingPong = LoopRepeat /** * Animation delta time. @@ -87,9 +96,7 @@ export default class Animation { * Set animation loop type. * @param loop - Animation loop type. */ - setLoop( - loop: typeof LoopOnce | typeof LoopRepeat | typeof LoopPingPong - ): Animation { + setLoop(loop: typeof LoopOnce | typeof LoopRepeat | typeof LoopPingPong): Animation { this.setProperty('loop', loop) this.loop = loop return this diff --git a/src/utils/AxesHelper.ts b/src/utils/AxesHelper.ts index 2a7028b..8c0d239 100644 --- a/src/utils/AxesHelper.ts +++ b/src/utils/AxesHelper.ts @@ -3,4 +3,4 @@ import { AxesHelper } from 'three' export default function (): AxesHelper { const axesHelper = new AxesHelper(1) return axesHelper -} \ No newline at end of file +} diff --git a/src/utils/CSS2DRenderer.ts b/src/utils/CSS2DRenderer.ts index 951e50c..536d216 100644 --- a/src/utils/CSS2DRenderer.ts +++ b/src/utils/CSS2DRenderer.ts @@ -42,7 +42,8 @@ export default function ( if (webgpu) { const originalRender = renderer.render.bind(renderer) - const regex = /translate\((-?\d+(?:\.\d+)?%(?:,-?\d+(?:\.\d+)?%)?)\)translate\((-?\d+(?:\.\d+)?)px,(-?\d+(?:\.\d+)?)px\)/ + const regex = + /translate\((-?\d+(?:\.\d+)?%(?:,-?\d+(?:\.\d+)?%)?)\)translate\((-?\d+(?:\.\d+)?)px,(-?\d+(?:\.\d+)?)px\)/ renderer.render = function (scene: THREE.Scene, camera: THREE.Camera) { originalRender(scene, camera) @@ -66,7 +67,8 @@ export default function ( // upstream y = -ndcY*half + half (WebGL) // desired y' = ndcY*half + half (WebGPU) // => y' = height - y - const h = (renderer as unknown as { getSize: () => { height: number } }).getSize().height + const h = (renderer as unknown as { getSize: () => { height: number } }).getSize() + .height const flippedY = h - yPx element.style.transform = `translate(${centerTranslate})translate(${xPx}px,${flippedY.toFixed(3)}px)` } diff --git a/src/utils/Callout.ts b/src/utils/Callout.ts index f6d033d..1afb276 100644 --- a/src/utils/Callout.ts +++ b/src/utils/Callout.ts @@ -11,12 +11,17 @@ export type LabelAnchor = 'center' | 'top-left' | 'top-right' | 'bottom-left' | /** Maps a LabelAnchor name to the CSS2DObject.center Vector2 used by CSS2DRenderer. */ function labelAnchorToVector2(anchor: LabelAnchor): THREE.Vector2 { switch (anchor) { - case 'top-left': return new THREE.Vector2(0, 0) - case 'top-right': return new THREE.Vector2(1, 0) - case 'bottom-left': return new THREE.Vector2(0, 1) - case 'bottom-right': return new THREE.Vector2(1, 1) + case 'top-left': + return new THREE.Vector2(0, 0) + case 'top-right': + return new THREE.Vector2(1, 0) + case 'bottom-left': + return new THREE.Vector2(0, 1) + case 'bottom-right': + return new THREE.Vector2(1, 1) case 'center': - default: return new THREE.Vector2(0.5, 0.5) + default: + return new THREE.Vector2(0.5, 0.5) } } @@ -112,12 +117,7 @@ export default class Callout { private camera: THREE.Camera | null = null private anchorFrameId: number | null = null - constructor( - start: Position, - end: Position, - component: ReactNode, - options: CalloutOptions = {} - ) { + constructor(start: Position, end: Position, component: ReactNode, options: CalloutOptions = {}) { const { color = 0xffffff, lineWidth = 1, @@ -149,9 +149,7 @@ export default class Callout { this.group = new THREE.Group() // Line geometry: 3 points for broken (start→corner→end), 2 points for straight (start→end) - this.lineGeometry = new THREE.BufferGeometry().setFromPoints( - this.buildLinePoints() - ) + this.lineGeometry = new THREE.BufferGeometry().setFromPoints(this.buildLinePoints()) if (dashed) { this.lineMaterial = new THREE.LineDashedMaterial({ diff --git a/src/utils/Fireworks.ts b/src/utils/Fireworks.ts new file mode 100644 index 0000000..dda10d6 --- /dev/null +++ b/src/utils/Fireworks.ts @@ -0,0 +1,708 @@ +import * as THREE from 'three' +import { NodeMaterial } from 'three/webgpu' +import type { Node } from 'three/webgpu' +import { + Fn, + If, + Discard, + and, + attribute, + cameraPosition, + cameraProjectionMatrix, + clamp, + cross, + dot, + float, + floor, + fract, + mix, + modelViewMatrix, + modelWorldMatrixInverse, + normalize, + oneMinus, + positionLocal, + pow, + select, + sin, + smoothstep, + uniform, + uv, + varying, + vec2, + vec3, + vec4 +} from 'three/tsl' + +export interface FireworksOptions { + /** Burst trail target length radius, baked into the geometry, default: 1 */ + radius?: number + /** Icosahedron subdivision level, baked into the geometry, default: 0 */ + detail?: number + /** Number of segments sampled along each trail, baked into the geometry, default: 5 */ + segments?: number + /** Gravity parabola coefficient, baked into the geometry, default: 1 */ + gravity?: number + /** Ribbon half-width, default: 0.1 */ + width?: number + /** Animation start time in seconds, default: 0 */ + startTime?: number + /** Launch phase duration in seconds, default: 1.5 */ + launchDuration?: number + /** Delay between launch and burst, may be negative to overlap, default: -0.4 */ + delayDuration?: number + /** Burst phase duration in seconds, default: 2 */ + burstDuration?: number + /** Burst height along the local Y axis, default: 6.5 */ + burstHeight?: number + /** Overall burst scale, default: 3.5 */ + burstScale?: number + /** Burst origin offset, default: (0, 0, 0) */ + burstPositionOffset?: THREE.Vector3 + /** Replay the effect when one cycle finishes, default: true */ + loop?: boolean + /** Base ribbon color, default: '#ffd4a0' */ + color?: THREE.ColorRepresentation + /** Head glow intensity, default: 9 */ + bloomIntensity?: number + /** Head glow length exponent, larger tightens the tip, default: 18 */ + bloomFalloff?: number + /** Noise dissolve time exponent, default: 3 */ + noiseFalloff?: number + /** Noise UV scale (across, along), default: (10, 200) */ + noiseScale?: THREE.Vector2 +} + +interface FireworksGeometryOptions { + radius: number + detail: number + segments: number + gravity: number +} + +interface NumberUniform { + value: number +} + +interface Vector3Uniform { + value: THREE.Vector3 +} + +interface Vector2Uniform { + value: THREE.Vector2 +} + +interface ColorUniform { + value: THREE.Color +} + +interface FireworksUniforms { + uWidth: NumberUniform + uTime: NumberUniform + uStartTime: NumberUniform + uLaunchDuration: NumberUniform + uDelayDuration: NumberUniform + uBurstDuration: NumberUniform + uBurstHeight: NumberUniform + uBurstScale: NumberUniform + uBloomIntensity: NumberUniform + uBloomFalloff: NumberUniform + uNoiseFalloff: NumberUniform + uBurstPositionOffset: Vector3Uniform + uNoiseScale: Vector2Uniform + uColor: ColorUniform +} + +// Walk up the parent chain to find the scene an object belongs to +function findScene(start: THREE.Object3D): THREE.Scene | null { + let current: THREE.Object3D | null = start + while (current) { + if ((current as THREE.Scene).isScene) return current as THREE.Scene + current = current.parent + } + return null +} + +// Cubic ease-out: maps normalized time t to (t - 1)^3 + 1, fast at the start +// and slow at the end. Drives the growth progress of the launch and burst phases. +const easeCubicOut = Fn( + ([t]: [Node<'float'>]) => { + const x = float(t).sub(1.0) + return x.mul(x).mul(x).add(1.0) + }, + { t: 'float', return: 'float' } +) + +// sin/dot based pseudo random: constant on integer coordinates, uncorrelated +// on neighbours. +const random2 = Fn( + ([co]: [Node<'vec2'>]) => { + return fract(sin(dot(co, vec2(12.9898, 78.233))).mul(47453.5453)) + }, + { co: 'vec2', return: 'float' } +) + +// 2D value noise: pseudo random values on the integer lattice blended with +// smoothstep interpolation. Used by the fragment stage for the dissolve. +const genericNoise2D = Fn( + ([p]: [Node<'vec2'>]) => { + const pos = vec2(p) + const b = floor(pos) + const fr = fract(pos) + // @types/three only exposes a float smoothstep (no vec2 overload), so the + // interpolation weights are built component-wise. + const f = vec2( + smoothstep(float(0.0), float(1.0), fr.x), + smoothstep(float(0.0), float(1.0), fr.y) + ) + const d = vec2(0.0, 1.0) + const n00 = random2(b) + const n10 = random2(b.add(vec2(d.y, d.x))) + const n01 = random2(b.add(vec2(d.x, d.y))) + const n11 = random2(b.add(vec2(d.y, d.y))) + return mix(mix(n00, n10, f.x), mix(n01, n11, f.x), f.y) + }, + { p: 'vec2', return: 'float' } +) + +// Build the ribbon geometry from icosahedron burst directions: +// 1. build a regular icosahedron, subdivide its faces by `detail`, project onto the sphere; +// 2. dedup directions so each unique ray becomes a single trail; +// 3. sample `segments` points along each ray with a gravity parabola, each sample +// emitting a left/right pair (uv.x = side, uv.y = normalized arc length); +// 4. append one launch placeholder expanded in the vertex shader. +function createFireworksGeometry(options: FireworksGeometryOptions): THREE.BufferGeometry { + const { radius, detail, segments, gravity } = options + + const t = (1 + Math.sqrt(5)) / 2 + const vertices = [ + -1, t, 0, 1, t, 0, -1, -t, 0, 1, -t, 0, 0, -1, t, 0, 1, t, 0, -1, -t, 0, 1, -t, t, 0, -1, t, 0, 1, -t, 0, + -1, -t, 0, 1 + ] + const faceIndices = [ + 0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8, 3, 9, 4, 3, 4, + 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1 + ] + + const vertexBuffer: THREE.Vector3[] = [] + + const getVertexByIndex = (index: number, vertex: THREE.Vector3): void => { + const stride = index * 3 + vertex.set(vertices[stride], vertices[stride + 1], vertices[stride + 2]) + } + + const pushVertex = (vertex: THREE.Vector3): void => { + // Unique directions only: shared icosahedron corners (and subdivided edge + // points) would otherwise emit many additive ribbons along the same ray. + if (vertexBuffer.findIndex((v) => v.equals(vertex)) < 0) { + vertexBuffer.push(vertex.clone()) + } + } + + const subdivideFace = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, faceDetail: number): void => { + const cols = faceDetail + 1 + const v: THREE.Vector3[][] = [] + + for (let i = 0; i <= cols; i++) { + v[i] = [] + const aj = a.clone().lerp(c, i / cols) + const bj = b.clone().lerp(c, i / cols) + const rows = cols - i + for (let j = 0; j <= rows; j++) { + if (j === 0 && i === cols) { + v[i][j] = aj + } else { + v[i][j] = aj.clone().lerp(bj, j / rows) + } + } + } + + for (let i = 0; i < cols; i++) { + for (let j = 0; j < 2 * (cols - i) - 1; j++) { + const k = Math.floor(j / 2) + if (j % 2 === 0) { + pushVertex(v[i][k + 1]) + pushVertex(v[i + 1][k]) + pushVertex(v[i][k]) + } else { + pushVertex(v[i][k + 1]) + pushVertex(v[i + 1][k + 1]) + pushVertex(v[i + 1][k]) + } + } + } + } + + const subdivide = (faceDetail: number): void => { + const a = new THREE.Vector3() + const b = new THREE.Vector3() + const c = new THREE.Vector3() + for (let i = 0; i < faceIndices.length; i += 3) { + getVertexByIndex(faceIndices[i], a) + getVertexByIndex(faceIndices[i + 1], b) + getVertexByIndex(faceIndices[i + 2], c) + subdivideFace(a, b, c, faceDetail) + } + } + + const buildGeometry = (trailRadius: number): THREE.BufferGeometry => { + const positions: number[] = [] + const tangents: number[] = [] + const uvs: number[] = [] + const isBurstTrails: number[] = [] + const indices: number[] = [] + + const vertex = new THREE.Vector3() + const temp = new THREE.Vector3() + const prev = new THREE.Vector3() + const tangent = new THREE.Vector3() + + for (let i = 0, il = vertexBuffer.length; i < il; i++) { + tangent.copy(vertexBuffer[i]).normalize() + vertex.copy(tangent).multiplyScalar(trailRadius) + prev.set(0, 0, 0) + const space = vertex.length() / segments + + positions.push(0, 0, 0, 0, 0, 0) + uvs.push(1, 0, 0, 0) + isBurstTrails.push(1, 1) + + let accumulatedDistance = 0 + for (let j = 1; j <= segments; j++) { + vertex + .copy(tangent) + .multiplyScalar(j * space) + .sub(temp.set(0, 0.5 * gravity * Math.pow(j * space, 2), 0)) + positions.push(vertex.x, vertex.y, vertex.z, vertex.x, vertex.y, vertex.z) + + temp.copy(vertex).sub(prev).normalize() + if (j === 1) { + tangents.push(temp.x, temp.y, temp.z, temp.x, temp.y, temp.z) + } + tangents.push(temp.x, temp.y, temp.z, temp.x, temp.y, temp.z) + + accumulatedDistance += vertex.distanceTo(prev) + uvs.push(1, accumulatedDistance, 0, accumulatedDistance) + isBurstTrails.push(1, 1) + prev.copy(vertex) + } + + for (let j = 0; j <= segments; j++) { + uvs[i * (segments + 1) * 4 + j * 4 + 1] /= accumulatedDistance + uvs[i * (segments + 1) * 4 + j * 4 + 3] /= accumulatedDistance + } + + for (let j = 0; j < segments; j++) { + const a = i * (segments + 1) * 2 + j * 2 + const b = i * (segments + 1) * 2 + j * 2 + 1 + const c = i * (segments + 1) * 2 + (j + 1) * 2 + const d = i * (segments + 1) * 2 + (j + 1) * 2 + 1 + indices.push(a, b, c, b, d, c) + } + } + + // One launch placeholder quad, kept far away until the launch phase is active. + const offset = positions.length / 3 + positions.push(0, 0, 0, 0, 0, 0) + tangents.push(1, 1, 1, 1, 1, 1) + uvs.push(1, 0, 0, 0) + isBurstTrails.push(0, 0) + positions.push(1, 1, 1, 1, 1, 1) + tangents.push(1, 1, 1, 1, 1, 1) + uvs.push(1, 1, 0, 1) + isBurstTrails.push(0, 0) + indices.push(offset, offset + 1, offset + 2, offset + 1, offset + 3, offset + 2) + + vertexBuffer.length = 0 + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('tangent', new THREE.Float32BufferAttribute(tangents, 3)) + geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)) + geometry.setAttribute('isBurstTrail', new THREE.Float32BufferAttribute(isBurstTrails, 1)) + geometry.setIndex(indices) + + return geometry + } + + subdivide(detail) + return buildGeometry(radius) +} + +interface FireworksMaterialBundle { + material: NodeMaterial + uniforms: FireworksUniforms +} + +function createFireworksMaterial(options: { + width: number + startTime: number + launchDuration: number + delayDuration: number + burstDuration: number + burstHeight: number + burstScale: number + burstPositionOffset: THREE.Vector3 + bloomIntensity: number + bloomFalloff: number + noiseFalloff: number + noiseScale: THREE.Vector2 + color: THREE.ColorRepresentation +}): FireworksMaterialBundle { + const uWidth = uniform(options.width) + const uTime = uniform(0.0) + const uStartTime = uniform(options.startTime) + const uLaunchDuration = uniform(options.launchDuration) + const uDelayDuration = uniform(options.delayDuration) + const uBurstDuration = uniform(options.burstDuration) + const uBurstHeight = uniform(options.burstHeight) + const uBurstScale = uniform(options.burstScale) + const uBurstPositionOffset = uniform(options.burstPositionOffset.clone()) + const uBloomIntensity = uniform(options.bloomIntensity) + const uBloomFalloff = uniform(options.bloomFalloff) + const uNoiseFalloff = uniform(options.noiseFalloff) + const uNoiseScale = uniform(options.noiseScale.clone()) + const uColor = uniform(new THREE.Color(options.color)) + + const material = new NodeMaterial() + material.transparent = true + material.depthWrite = false + material.blending = THREE.AdditiveBlending + material.side = THREE.DoubleSide + + // MeshLocal UV: x in [-1, 1] is the left/right half width, y in [0, 1] the + // progress along the trail length. Shared between the vertex and fragment + // stages through a varying. + const vTrailUv = varying(vec2(0.0)) + // Eased growth progress of the launch or burst phase. + const vPhaseEased = varying(float(0)) + const vIsBurstTrail = varying(float(0)) + + material.vertexNode = Fn(() => { + const isBurstTrailAttr = attribute<'float'>('isBurstTrail', 'float') + const tangentAttr = attribute<'vec3'>('tangent', 'vec3') + + const trailUv = vec2(uv().x.mul(2.0).sub(1.0), uv().y) + vTrailUv.assign(trailUv) + vIsBurstTrail.assign(isBurstTrailAttr) + + const elapsed = uTime.sub(uStartTime) + const burstPhase = elapsed.sub(uLaunchDuration).sub(uDelayDuration).div(uBurstDuration) + const launchPhase = elapsed.div(uLaunchDuration) + const phaseRaw = select(isBurstTrailAttr.greaterThan(0.5), burstPhase, launchPhase) + vPhaseEased.assign(easeCubicOut(phaseRaw)) + + const active = and(phaseRaw.greaterThan(0.0), phaseRaw.lessThan(1.0)) + const burstOrigin = vec3( + uBurstPositionOffset.x, + uBurstPositionOffset.y.add(uBurstHeight), + uBurstPositionOffset.z + ) + const burstPos = positionLocal.mul(uBurstScale).add(burstOrigin) + const launchPos = positionLocal.mul(burstOrigin) + const center = select(isBurstTrailAttr.greaterThan(0.5), burstPos, launchPos).toVar() + const localCamera = modelWorldMatrixInverse.mul(vec4(cameraPosition, 1.0)).xyz + const look = normalize(localCamera.sub(center)) + const localTangent = select(isBurstTrailAttr.greaterThan(0.5), tangentAttr, normalize(burstOrigin)) + const side = normalize(cross(look, localTangent)) + const ribbon = center.add(side.mul(uWidth).mul(trailUv.x).mul(0.5)) + + // Inactive phases move the vertices far away so the clip position stays + // defined and no bright dot is left at the origin. + const position = select(active, ribbon, vec3(0.0, 1.0e5, 0.0)) + + // The vertex node output is assigned straight to gl_Position by the + // WebGLNodesHandler, so it has to be a fully transformed vec4. + return cameraProjectionMatrix.mul(modelViewMatrix).mul(vec4(position, 1.0)) + })() + + material.fragmentNode = Fn(() => { + // Discard the part that has not grown in yet. + If(vTrailUv.y.greaterThan(vPhaseEased), () => { + Discard() + }) + + const burstTrail = vIsBurstTrail.greaterThan(0.5) + const uvScaled = vTrailUv.mul(uNoiseScale) + const n = oneMinus(genericNoise2D(uvScaled)) + const delayStart = select(burstTrail, float(0.15), float(0.25)) + const falloffMul = select(burstTrail, float(1.0), float(1.3)) + const delayFactor = pow(vPhaseEased, uNoiseFalloff.mul(falloffMul)) + .sub(delayStart) + .div(oneMinus(delayStart)) + const noiseFade = smoothstep(float(0.0), vPhaseEased, vTrailUv.y.sub(delayFactor)) + If(n.greaterThan(noiseFade), () => { + Discard() + }) + + // Head bloom: colorFactor peaks at the growth front, bloomFade kills the + // tip near the end of the phase, the final color is base * (1 + effect). + const colorFactor = clamp(oneMinus(vPhaseEased.sub(vTrailUv.y)), float(0.0), float(1.0)) + const bloomFade = oneMinus(smoothstep(float(0.85), float(1.0), vPhaseEased)) + const bloomEffect = uBloomIntensity.mul(pow(colorFactor, uBloomFalloff)).mul(bloomFade) + + return vec4(uColor.rgb.mul(float(1.0).add(bloomEffect)), 1.0) + })() + + return { + material, + uniforms: { + uWidth: uWidth as unknown as NumberUniform, + uTime: uTime as unknown as NumberUniform, + uStartTime: uStartTime as unknown as NumberUniform, + uLaunchDuration: uLaunchDuration as unknown as NumberUniform, + uDelayDuration: uDelayDuration as unknown as NumberUniform, + uBurstDuration: uBurstDuration as unknown as NumberUniform, + uBurstHeight: uBurstHeight as unknown as NumberUniform, + uBurstScale: uBurstScale as unknown as NumberUniform, + uBloomIntensity: uBloomIntensity as unknown as NumberUniform, + uBloomFalloff: uBloomFalloff as unknown as NumberUniform, + uNoiseFalloff: uNoiseFalloff as unknown as NumberUniform, + uBurstPositionOffset: uBurstPositionOffset as unknown as Vector3Uniform, + uNoiseScale: uNoiseScale as unknown as Vector2Uniform, + uColor: uColor as unknown as ColorUniform + } + } +} + +export default class Fireworks extends THREE.Mesh { + private animationId: number | null = null + private lastTime: number + private disposed = false + private loopEnabled: boolean + private readonly uniforms: FireworksUniforms + private readonly nodeMaterial: NodeMaterial + private readonly origColor: THREE.Color + private readonly onAdded: () => void + + constructor(options: FireworksOptions = {}) { + const { + radius = 1.0, + detail = 0, + segments = 5, + gravity = 1.0, + width = 0.1, + startTime = 0.0, + launchDuration = 1.5, + delayDuration = -0.4, + burstDuration = 2.0, + burstHeight = 6.5, + burstScale = 3.5, + burstPositionOffset = new THREE.Vector3(0, 0, 0), + loop = true, + color = '#ffd4a0', + bloomIntensity = 9.0, + bloomFalloff = 18.0, + noiseFalloff = 3.0, + noiseScale = new THREE.Vector2(10.0, 200.0) + } = options + + const geometry = createFireworksGeometry({ radius, detail, segments, gravity }) + const { material, uniforms } = createFireworksMaterial({ + width, + startTime, + launchDuration, + delayDuration, + burstDuration, + burstHeight, + burstScale, + burstPositionOffset, + bloomIntensity, + bloomFalloff, + noiseFalloff, + noiseScale, + color + }) + + super(geometry, material) + + this.name = 'Fireworks' + // Vertices are expanded in the vertex stage, the geometry bounds no longer + // describe what is actually drawn. + this.frustumCulled = false + + this.uniforms = uniforms + this.nodeMaterial = material + this.loopEnabled = loop + this.origColor = new THREE.Color(color) + + // Auto-detect the renderer from the scene the mesh belongs to: WebGPU + // converts linear to sRGB on final output, so pre-correct the color so it + // matches the WebGL output. + this.onAdded = () => { + const renderer = findScene(this)?.userData?.renderer as { isWebGPURenderer?: boolean } | undefined + if (renderer) this.applyColor(renderer.isWebGPURenderer === true) + } + this.addEventListener('added', this.onAdded) + + this.lastTime = performance.now() + this.startAnimate() + } + + // Set the color according to the renderer type; WebGPU needs sRGB brightness + // pre-correction so it matches the WebGL output. + private applyColor(isWebGPU: boolean): void { + const { r, g, b } = this.origColor + + if (isWebGPU) { + const brightness = 0.86 + this.uniforms.uColor.value.setRGB( + Math.pow(r, 2.2) * brightness, + Math.pow(g, 2.2) * brightness, + Math.pow(b, 2.2) * brightness + ) + } else { + this.uniforms.uColor.value.copy(this.origColor) + } + } + + private startAnimate(): void { + const tick = () => { + this.animationId = requestAnimationFrame(tick) + + const now = performance.now() + // Clamp the delta so a background tab does not jump the phase on return. + const delta = Math.min((now - this.lastTime) / 1000, 0.1) + this.lastTime = now + this.update(delta) + } + + tick() + } + + /** Advance the animation time in seconds. */ + public update(deltaSeconds: number): void { + const { uTime, uStartTime, uLaunchDuration, uDelayDuration, uBurstDuration } = this.uniforms + const duration = uLaunchDuration.value + uDelayDuration.value + uBurstDuration.value + + if (this.loopEnabled && uTime.value - uStartTime.value > duration) { + uTime.value = 0.0 + } else { + uTime.value += deltaSeconds + } + } + + /** Reset the current time to 0 and replay from `startTime`. */ + public restart(): void { + this.uniforms.uTime.value = 0.0 + } + + public dispose(): void { + if (this.disposed) return + this.disposed = true + + if (this.animationId !== null) { + cancelAnimationFrame(this.animationId) + this.animationId = null + } + + this.removeEventListener('added', this.onAdded) + this.parent?.remove(this) + + this.geometry.dispose() + this.nodeMaterial.dispose() + } + + get loop(): boolean { + return this.loopEnabled + } + + set loop(value: boolean) { + this.loopEnabled = value + } + + get width(): number { + return this.uniforms.uWidth.value + } + + set width(value: number) { + this.uniforms.uWidth.value = value + } + + get startTime(): number { + return this.uniforms.uStartTime.value + } + + set startTime(value: number) { + this.uniforms.uStartTime.value = value + } + + get launchDuration(): number { + return this.uniforms.uLaunchDuration.value + } + + set launchDuration(value: number) { + this.uniforms.uLaunchDuration.value = value + } + + get delayDuration(): number { + return this.uniforms.uDelayDuration.value + } + + set delayDuration(value: number) { + this.uniforms.uDelayDuration.value = value + } + + get burstDuration(): number { + return this.uniforms.uBurstDuration.value + } + + set burstDuration(value: number) { + this.uniforms.uBurstDuration.value = value + } + + get burstHeight(): number { + return this.uniforms.uBurstHeight.value + } + + set burstHeight(value: number) { + this.uniforms.uBurstHeight.value = value + } + + get burstScale(): number { + return this.uniforms.uBurstScale.value + } + + set burstScale(value: number) { + this.uniforms.uBurstScale.value = value + } + + get bloomIntensity(): number { + return this.uniforms.uBloomIntensity.value + } + + set bloomIntensity(value: number) { + this.uniforms.uBloomIntensity.value = value + } + + get bloomFalloff(): number { + return this.uniforms.uBloomFalloff.value + } + + set bloomFalloff(value: number) { + this.uniforms.uBloomFalloff.value = value + } + + get noiseFalloff(): number { + return this.uniforms.uNoiseFalloff.value + } + + set noiseFalloff(value: number) { + this.uniforms.uNoiseFalloff.value = value + } + + get color(): THREE.Color { + return this.uniforms.uColor.value + } + + set color(value: THREE.ColorRepresentation) { + this.uniforms.uColor.value.set(value) + } + + get burstPositionOffset(): THREE.Vector3 { + return this.uniforms.uBurstPositionOffset.value + } + + get noiseScale(): THREE.Vector2 { + return this.uniforms.uNoiseScale.value + } +} diff --git a/src/utils/Light.ts b/src/utils/Light.ts index 236010b..209f730 100644 --- a/src/utils/Light.ts +++ b/src/utils/Light.ts @@ -67,12 +67,7 @@ export class LightGradient { private onComplete?: () => void constructor(light: THREE.Light | THREE.Object3D, options: LightGradientOptions = {}) { - const { - color: targetColor, - intensity: targetIntensity, - duration = 1000, - onComplete - } = options + const { color: targetColor, intensity: targetIntensity, duration = 1000, onComplete } = options // Support both single Light and Group (Object3D with Light children). const collected: THREE.Light[] = [] @@ -94,9 +89,7 @@ export class LightGradient { targetColor ? new THREE.Color(targetColor) : this.startColors[0].clone() ) this.startIntensities = this.targets.map((t) => t.intensity) - this.endIntensities = this.targets.map( - () => targetIntensity ?? this.startIntensities[0] - ) + this.endIntensities = this.targets.map(() => targetIntensity ?? this.startIntensities[0]) this.duration = duration this.startTime = performance.now() this.onComplete = onComplete diff --git a/src/utils/LightPillar.ts b/src/utils/LightPillar.ts index ab73771..69aca39 100644 --- a/src/utils/LightPillar.ts +++ b/src/utils/LightPillar.ts @@ -18,7 +18,7 @@ import { mix, sin, distance, - oneMinus, + oneMinus } from 'three/tsl' export interface LightPillarOptions { @@ -103,10 +103,19 @@ function createConeMaterial(color: number, speed: number, opacity: number): Mate return vec4(uColor.rgb, alpha) })() - return { material, timeUniform: uTime as unknown as { value: number }, colorUniform: uColor as unknown as { value: THREE.Color } } + return { + material, + timeUniform: uTime as unknown as { value: number }, + colorUniform: uColor as unknown as { value: THREE.Color } + } } -function createGlowMaterial(color: number, size: number, speed: number, opacity: number): MaterialBundle { +function createGlowMaterial( + color: number, + size: number, + speed: number, + opacity: number +): MaterialBundle { const uTime = uniform(0) const uColor = uniform(new THREE.Color(color)) const uSize = uniform(size) @@ -143,7 +152,11 @@ function createGlowMaterial(color: number, size: number, speed: number, opacity: return vec4(uColor.rgb, core.mul(uOpacity)) })() - return { material, timeUniform: uTime as unknown as { value: number }, colorUniform: uColor as unknown as { value: THREE.Color } } + return { + material, + timeUniform: uTime as unknown as { value: number }, + colorUniform: uColor as unknown as { value: THREE.Color } + } } // Both pulse rings share one material; the half cycle offset between them comes @@ -178,7 +191,11 @@ function createRingMaterial(color: number, speed: number, opacity: number): Mate return vec4(uColor.rgb, alpha) })() - return { material, timeUniform: uTime as unknown as { value: number }, colorUniform: uColor as unknown as { value: THREE.Color } } + return { + material, + timeUniform: uTime as unknown as { value: number }, + colorUniform: uColor as unknown as { value: THREE.Color } + } } function fillAttribute(geometry: THREE.BufferGeometry, name: string, value: number): void { @@ -233,7 +250,7 @@ export default class LightPillar extends THREE.Group { segments = 32, glow = true, ring = true, - ringRadius = radius * 3, + ringRadius = radius * 3 } = options this.origColor = new THREE.Color(color) diff --git a/src/utils/ModelLoader.ts b/src/utils/ModelLoader.ts index 4728ded..4876ac7 100644 --- a/src/utils/ModelLoader.ts +++ b/src/utils/ModelLoader.ts @@ -1,5 +1,12 @@ import * as THREE from 'three' -import { GLTFLoader as THREEGLTFLoader, FBXLoader as THREEFBXLoader, OBJLoader as THREEOBJLoader, MTLLoader as THREEMTLLoader, DRACOLoader, mergeVertices } from 'three-stdlib' +import { + GLTFLoader as THREEGLTFLoader, + FBXLoader as THREEFBXLoader, + OBJLoader as THREEOBJLoader, + MTLLoader as THREEMTLLoader, + DRACOLoader, + mergeVertices +} from 'three-stdlib' interface LoadEvent { type: 'cache' | 'fetch' | 'parse' @@ -98,15 +105,15 @@ function refreshTextures(model: THREE.Group): void { // Helper function to validate if ArrayBuffer contains valid model data (not HTML) function isValidModelData(buffer: ArrayBuffer): boolean { if (buffer.byteLength < 4) return false - + const view = new Uint8Array(buffer, 0, Math.min(buffer.byteLength, 100)) const text = new TextDecoder('utf-8').decode(view.slice(0, Math.min(view.length, 50))) - + // Check if it starts with HTML tags if (text.trim().startsWith(' { - const writeTransaction = db.transaction([OBJECT_STORE], 'readwrite') - const writeObjectStore = writeTransaction.objectStore(OBJECT_STORE) - writeObjectStore.put(data, key) - resolve(data) - }).catch(reject) + fetchWithProgress(url, onProgress) + .then((data) => { + const writeTransaction = db.transaction([OBJECT_STORE], 'readwrite') + const writeObjectStore = writeTransaction.objectStore(OBJECT_STORE) + writeObjectStore.put(data, key) + resolve(data) + }) + .catch(reject) return } onProgress?.({ type: 'cache', progress: 100 }) @@ -312,19 +321,26 @@ export default function fileLoader( }) } -async function fetchWithProgress(url: string, onProgress?: (event: LoadEvent) => void): Promise { +async function fetchWithProgress( + url: string, + onProgress?: (event: LoadEvent) => void +): Promise { const response = await fetch(url) - + // Validate response content type to prevent HTML error pages from being treated as model data const contentType = response.headers.get('content-type') if (contentType && contentType.includes('text/html')) { - throw new Error(`Failed to load model from ${url}: Server returned HTML instead of model data. Check if the file exists and the URL is correct.`) + throw new Error( + `Failed to load model from ${url}: Server returned HTML instead of model data. Check if the file exists and the URL is correct.` + ) } - + if (!response.ok) { - throw new Error(`Failed to load model from ${url}: HTTP ${response.status} ${response.statusText}`) + throw new Error( + `Failed to load model from ${url}: HTTP ${response.status} ${response.statusText}` + ) } - + const contentLength = response.headers.get('content-length') const total = contentLength ? parseInt(contentLength, 10) : 0 const reader = response.body?.getReader() diff --git a/src/utils/Picker.ts b/src/utils/Picker.ts index c74f025..f99bec9 100644 --- a/src/utils/Picker.ts +++ b/src/utils/Picker.ts @@ -61,14 +61,14 @@ class Picker { private pendingMove: PointerEvent | null = null private listeners: Record> = { click: new Set(), - hover: new Set(), + hover: new Set() } constructor( scene: THREE.Scene, camera: THREE.Camera, domElement: HTMLElement, - options: PickerOptions = {}, + options: PickerOptions = {} ) { const { targets, @@ -78,7 +78,7 @@ class Picker { clickThreshold = 5, layers, near, - far, + far } = options this.scene = scene @@ -141,7 +141,7 @@ class Picker { intersection, intersections, pointer: this.pointer.clone(), - nativeEvent, + nativeEvent } } diff --git a/src/utils/SweepLight.ts b/src/utils/SweepLight.ts index 0b8fdb0..c1fef2c 100644 --- a/src/utils/SweepLight.ts +++ b/src/utils/SweepLight.ts @@ -18,7 +18,7 @@ import { min, mod, floor, - If, + If } from 'three/tsl' export interface SweepLightOptions { @@ -53,17 +53,12 @@ interface SweepMaterialOptions { // why the sweep used to silently disappear on the WebGL renderer.) The per-mesh // sweep range is therefore supplied through the `aSweepBounds` geometry // attribute instead of a per-material uniform. -function createSweepMaterial( - options: SweepMaterialOptions -): { material: NodeMaterial; timeUniform: ReturnType; colorUniform: { value: THREE.Color } } { - const { - color, - speed, - width, - intensity, - direction, - loopType, - } = options +function createSweepMaterial(options: SweepMaterialOptions): { + material: NodeMaterial + timeUniform: ReturnType + colorUniform: { value: THREE.Color } +} { + const { color, speed, width, intensity, direction, loopType } = options const uTime = uniform(0) const uColor = uniform(new THREE.Color(color)) @@ -133,16 +128,9 @@ function createSweepMaterial( const band = pow(float(1.0).sub(smoothstep(float(0.0), bandWidth, dist)), float(1.5)) const trailWidth = bandWidth.mul(3.0) - const trail = pow( - max(float(0.0), float(1.0).sub(dist.div(trailWidth))), - float(2.0) - ).mul(0.4) + const trail = pow(max(float(0.0), float(1.0).sub(dist.div(trailWidth))), float(2.0)).mul(0.4) - const alpha = clamp( - band.add(trail).mul(uIntensity), - float(0.0), - float(1.0) - ) + const alpha = clamp(band.add(trail).mul(uIntensity), float(0.0), float(1.0)) return vec4(uColor.rgb, alpha) })() @@ -166,10 +154,7 @@ class SweepLight { private isPaused: boolean = false private animationId: number | null = null - constructor( - target: SweepTarget, - options: SweepLightOptions = {} - ) { + constructor(target: SweepTarget, options: SweepLightOptions = {}) { this.target = target const { color = 0x00ffff, @@ -177,7 +162,7 @@ class SweepLight { width = 0.3, intensity = 1.5, direction = 0, - loop = THREE.LoopRepeat, + loop = THREE.LoopRepeat } = options const loopType = loop === THREE.LoopOnce ? 0 : loop === THREE.LoopPingPong ? 2 : 1 @@ -201,7 +186,7 @@ class SweepLight { width, intensity, direction, - loopType, + loopType }) this.material = material this.timeUniform = timeUniform @@ -258,10 +243,7 @@ class SweepLight { boundsArray[i * 2] = minPos boundsArray[i * 2 + 1] = maxPos } - sweepGeometry.setAttribute( - 'aSweepBounds', - new THREE.BufferAttribute(boundsArray, 2) - ) + sweepGeometry.setAttribute('aSweepBounds', new THREE.BufferAttribute(boundsArray, 2)) this.geometries.push(sweepGeometry) const sweepMesh = new THREE.Mesh(sweepGeometry, material) From 5f3d209af3893109b1427387c885d3f12bd22e92 Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Wed, 21 Oct 2026 11:51:20 +0800 Subject: [PATCH 09/13] docs(demos): improve height fog demo - Move pillars closer, enlarge, and darken them so they read as silhouettes against the fog layer - Consolidate the three disposal refs into one meshesRef - Sync English and Chinese doc snippets with the demo --- docs/guide/effects/height-fog.md | 123 +++++++++++++++++- .../current/effects/height-fog.md | 123 +++++++++++++++++- docs/src/components/effects/HeightFog.tsx | 61 ++++++++- 3 files changed, 302 insertions(+), 5 deletions(-) diff --git a/docs/guide/effects/height-fog.md b/docs/guide/effects/height-fog.md index c1180af..7f68778 100644 --- a/docs/guide/effects/height-fog.md +++ b/docs/guide/effects/height-fog.md @@ -15,16 +15,76 @@ import HeightFog from '@site/src/components/effects/HeightFog' Screen-space height fog: an exponential fog layer whose density falls off with world height, with flowing noise on the fog plane and directional inscattering towards a light source. Every example below can be viewed with either the **WebGPU** (default) or the **WebGL** renderer — switch tabs to compare. +The demo below drops a ground plane plus a row of pillars receding into the distance, so the fog layers over the geometry and the height gradient becomes visible. + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' import { Scene, HeightFog } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + // Columns receding into the distance: [x, z, width, height]. + const pillars: [number, number, number, number][] = [ + [-5, 5, 2.8, 13], + [6, 2, 3.2, 17], + [-2, -4, 2.6, 11], + [8, -9, 3.4, 21], + [-9, -15, 3.0, 15], + [3, -22, 2.8, 19], + [-6, -30, 3.4, 23] + ] export default function App() { + const meshesRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 5, 14) + camera.lookAt(0, 7, -12) + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(400, 400), + new THREE.MeshStandardMaterial({ color: 0x2f3440, roughness: 1 }) + ) + ground.rotation.x = -Math.PI / 2 + + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial({ color: 0x5c6579, roughness: 0.85 }) + const columns = pillars.map(([x, z, width, height]) => { + const column = new THREE.Mesh(geometry, material) + column.position.set(x, height / 2, z) + column.scale.set(width, height, width) + return column + }) + + meshesRef.current = [ground, ...columns] + scene.add(...meshesRef.current) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + meshesRef.current.forEach((mesh) => { + mesh.parent?.remove(mesh) + mesh.geometry.dispose() + const meshMaterial = mesh.material as THREE.Material + meshMaterial.dispose() + }) + meshesRef.current = [] + } + }, []) + return ( - + ) @@ -35,11 +95,70 @@ Screen-space height fog: an exponential fog layer whose density falls off with w ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' import { Scene, HeightFog } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + // Columns receding into the distance: [x, z, width, height]. + const pillars: [number, number, number, number][] = [ + [-5, 5, 2.8, 13], + [6, 2, 3.2, 17], + [-2, -4, 2.6, 11], + [8, -9, 3.4, 21], + [-9, -15, 3.0, 15], + [3, -22, 2.8, 19], + [-6, -30, 3.4, 23] + ] export default function App() { + const meshesRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 5, 14) + camera.lookAt(0, 7, -12) + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(400, 400), + new THREE.MeshStandardMaterial({ color: 0x2f3440, roughness: 1 }) + ) + ground.rotation.x = -Math.PI / 2 + + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial({ color: 0x5c6579, roughness: 0.85 }) + const columns = pillars.map(([x, z, width, height]) => { + const column = new THREE.Mesh(geometry, material) + column.position.set(x, height / 2, z) + column.scale.set(width, height, width) + return column + }) + + meshesRef.current = [ground, ...columns] + scene.add(...meshesRef.current) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + meshesRef.current.forEach((mesh) => { + mesh.parent?.remove(mesh) + mesh.geometry.dispose() + const meshMaterial = mesh.material as THREE.Material + meshMaterial.dispose() + }) + meshesRef.current = [] + } + }, []) + return ( - + ) diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md index acf8483..ced9af5 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/height-fog.md @@ -14,16 +14,76 @@ import HeightFogComponent from '@site/src/components/effects/HeightFog' 屏幕空间高度雾:密度随世界高度指数衰减的雾层,支持雾面流动噪声与朝向光源的方向散射。下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 +下方示例放置了一个地面和一排向远处延伸的立柱,让雾气覆盖在几何体之上,从而清晰展示高度渐变效果。 + ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' import { Scene, HeightFog } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + // 向远处延伸的立柱:[x, z, width, height] + const pillars: [number, number, number, number][] = [ + [-5, 5, 2.8, 13], + [6, 2, 3.2, 17], + [-2, -4, 2.6, 11], + [8, -9, 3.4, 21], + [-9, -15, 3.0, 15], + [3, -22, 2.8, 19], + [-6, -30, 3.4, 23] + ] export default function App() { + const meshesRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 5, 14) + camera.lookAt(0, 7, -12) + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(400, 400), + new THREE.MeshStandardMaterial({ color: 0x2f3440, roughness: 1 }) + ) + ground.rotation.x = -Math.PI / 2 + + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial({ color: 0x5c6579, roughness: 0.85 }) + const columns = pillars.map(([x, z, width, height]) => { + const column = new THREE.Mesh(geometry, material) + column.position.set(x, height / 2, z) + column.scale.set(width, height, width) + return column + }) + + meshesRef.current = [ground, ...columns] + scene.add(...meshesRef.current) + } + + // 卸载时清理 + useEffect(() => { + return () => { + meshesRef.current.forEach((mesh) => { + mesh.parent?.remove(mesh) + mesh.geometry.dispose() + const meshMaterial = mesh.material as THREE.Material + meshMaterial.dispose() + }) + meshesRef.current = [] + } + }, []) + return ( - + ) @@ -34,11 +94,70 @@ import HeightFogComponent from '@site/src/components/effects/HeightFog' ```tsx + import { useRef, useEffect } from 'react' + import * as THREE from 'three' import { Scene, HeightFog } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + // 向远处延伸的立柱:[x, z, width, height] + const pillars: [number, number, number, number][] = [ + [-5, 5, 2.8, 13], + [6, 2, 3.2, 17], + [-2, -4, 2.6, 11], + [8, -9, 3.4, 21], + [-9, -15, 3.0, 15], + [3, -22, 2.8, 19], + [-6, -30, 3.4, 23] + ] export default function App() { + const meshesRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 5, 14) + camera.lookAt(0, 7, -12) + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(400, 400), + new THREE.MeshStandardMaterial({ color: 0x2f3440, roughness: 1 }) + ) + ground.rotation.x = -Math.PI / 2 + + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial({ color: 0x5c6579, roughness: 0.85 }) + const columns = pillars.map(([x, z, width, height]) => { + const column = new THREE.Mesh(geometry, material) + column.position.set(x, height / 2, z) + column.scale.set(width, height, width) + return column + }) + + meshesRef.current = [ground, ...columns] + scene.add(...meshesRef.current) + } + + // 卸载时清理 + useEffect(() => { + return () => { + meshesRef.current.forEach((mesh) => { + mesh.parent?.remove(mesh) + mesh.geometry.dispose() + const meshMaterial = mesh.material as THREE.Material + meshMaterial.dispose() + }) + meshesRef.current = [] + } + }, []) + return ( - + ) diff --git a/docs/src/components/effects/HeightFog.tsx b/docs/src/components/effects/HeightFog.tsx index a2cab87..5ad1cc0 100644 --- a/docs/src/components/effects/HeightFog.tsx +++ b/docs/src/components/effects/HeightFog.tsx @@ -1,12 +1,71 @@ +import { useRef, useEffect } from 'react' +import * as THREE from 'three' import { Scene, HeightFog } from 'react-three-lite' +import type { SceneComponents } from 'react-three-lite' interface HeightFogProps { rendererType?: 'webgpu' | 'webgl' } +// Columns receding into the distance: [x, z, width, height]. +const pillars: [number, number, number, number][] = [ + [-5, 5, 2.8, 13], + [6, 2, 3.2, 17], + [-2, -4, 2.6, 11], + [8, -9, 3.4, 21], + [-9, -15, 3.0, 15], + [3, -22, 2.8, 19], + [-6, -30, 3.4, 23] +] + export default function HeightFogComponent({ rendererType }: HeightFogProps) { + const meshesRef = useRef([]) + + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 5, 14) + camera.lookAt(0, 7, -12) + + const ground = new THREE.Mesh( + new THREE.PlaneGeometry(400, 400), + new THREE.MeshStandardMaterial({ color: 0x2f3440, roughness: 1 }) + ) + ground.rotation.x = -Math.PI / 2 + + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshStandardMaterial({ color: 0x5c6579, roughness: 0.85 }) + const columns = pillars.map(([x, z, width, height]) => { + const column = new THREE.Mesh(geometry, material) + column.position.set(x, height / 2, z) + column.scale.set(width, height, width) + return column + }) + + meshesRef.current = [ground, ...columns] + scene.add(...meshesRef.current) + } + + // Cleanup on unmount + useEffect(() => { + return () => { + meshesRef.current.forEach((mesh) => { + mesh.parent?.remove(mesh) + mesh.geometry.dispose() + const meshMaterial = mesh.material as THREE.Material + meshMaterial.dispose() + }) + meshesRef.current = [] + } + }, []) + return ( - + ) From 5c64c2980d11acd1b3480aa753cab6b40d85250e Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Wed, 21 Oct 2026 15:43:24 +0800 Subject: [PATCH 10/13] feat(effects): add shallow ocean water effect - Port the 14 wave directional spectrum shared by the CPU sampler and the GPU displacement - Shade the surface with Fresnel reflectance, GGX sun glitter, foam, water depth colors and planar reflection/refraction - Capture reflections and refractions for the WebGL renderer while WebGPU uses reflector and viewport shared texture nodes - Tint the underside and ease the underwater transition from the sampled surface height - Add the demo, English/Chinese docs and unit tests --- docs/guide/effects/water.md | 90 ++++ .../current.json | 4 + .../current/effects/water.md | 89 ++++ docs/sidebars.js | 2 +- docs/src/components/effects/Water.tsx | 25 ++ src/__tests__/Water.test.ts | 172 ++++++++ src/__tests__/api.test.ts | 2 + src/components/Water.tsx | 255 +++++++++++ src/index.ts | 3 + src/utils/WaterMaterial.ts | 410 ++++++++++++++++++ src/utils/WaterNoise.ts | 43 ++ src/utils/WaterReflection.ts | 84 ++++ src/utils/WaterShading.ts | 183 ++++++++ src/utils/WaterSpectrum.ts | 301 +++++++++++++ src/utils/WaterWaveNodes.ts | 184 ++++++++ 15 files changed, 1846 insertions(+), 1 deletion(-) create mode 100644 docs/guide/effects/water.md create mode 100644 docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/water.md create mode 100644 docs/src/components/effects/Water.tsx create mode 100644 src/__tests__/Water.test.ts create mode 100644 src/components/Water.tsx create mode 100644 src/utils/WaterMaterial.ts create mode 100644 src/utils/WaterNoise.ts create mode 100644 src/utils/WaterReflection.ts create mode 100644 src/utils/WaterShading.ts create mode 100644 src/utils/WaterSpectrum.ts create mode 100644 src/utils/WaterWaveNodes.ts diff --git a/docs/guide/effects/water.md b/docs/guide/effects/water.md new file mode 100644 index 0000000..d13dc7b --- /dev/null +++ b/docs/guide/effects/water.md @@ -0,0 +1,90 @@ +--- +lang: en-US +title: Water +--- + +## Type + +Component + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import Water from '@site/src/components/effects/Water' + +## Default Usage + +Every example below can be viewed with either the **WebGPU** (default) or the **WebGL** renderer — switch tabs to compare. + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, Water } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 1.5, 3) + } + + return ( + + + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, Water } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 1.5, 3) + } + + return ( + + + + ) + } + ``` + + + +## Props + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| sunDirection | Vector3 \| [number, number, number] | [-0.58, 0.1, -0.81] | Direction from the water surface towards the sun | +| captureResolution | number | 512 | Longest edge of the planar reflection and refraction captures, in device pixels | +| segments | number | 240 | Plane subdivisions per axis | +| size | number | 420 | Size of the water patch, in world units | + +## Renderer Support + +The water is shaded with TSL and runs on both renderers: + +- **WebGPU** uses the `reflector()` node for planar reflection and `viewportSharedTexture()` for refraction, both handled by the render graph. +- **WebGL** renders a mirrored planar reflection and a refraction capture of the scene (water excluded) into half float render targets every frame, then samples them in the water shader. + +Captures are sized from the drawing buffer, clamped between 14% and 42% of its longest edge and scaled by `captureResolution`. When the camera drops below the animated surface, the water shades its underside and the planar captures are skipped until the camera rises again. diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json index bf707c2..c38f68b 100644 --- a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current.json @@ -99,6 +99,10 @@ "message": "下雪效果", "description": "The label for doc 'effects/snow' in sidebar 'guideSidebar'" }, + "sidebar.guideSidebar.doc.effects/water": { + "message": "海洋水面", + "description": "The label for doc 'effects/water' in sidebar 'guideSidebar'" + }, "sidebar.guideSidebar.doc.effects/height-fog": { "message": "高度雾", "description": "The label for doc 'effects/height-fog' in sidebar 'guideSidebar'" diff --git a/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/water.md b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/water.md new file mode 100644 index 0000000..c166ca2 --- /dev/null +++ b/docs/i18n/zh-CN/docusaurus-plugin-content-docs/current/effects/water.md @@ -0,0 +1,89 @@ +--- +title: 海洋水面 +--- + +## 类型 + +组件 + +import Tabs from '@theme/Tabs' +import TabItem from '@theme/TabItem' +import Water from '@site/src/components/effects/Water' + +## 默认用法 + +下面的每个示例都可以用 **WebGPU**(默认)或 **WebGL** 渲染器查看 —— 切换标签页进行对比。 + + + + + + ```tsx + import * as THREE from 'three' + import { Scene, Water } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 1.5, 3) + } + + return ( + + + + ) + } + ``` + + + + + ```tsx + import * as THREE from 'three' + import { Scene, Water } from 'react-three-lite' + import type { SceneComponents } from 'react-three-lite' + + export default function App() { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 1.5, 3) + } + + return ( + + + + ) + } + ``` + + + +## 属性 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| sunDirection | Vector3 \| [number, number, number] | [-0.58, 0.1, -0.81] | 从水面指向太阳的方向 | +| captureResolution | number | 512 | 平面反射与折射捕获的最长边像素数 | +| segments | number | 240 | 平面每轴细分数 | +| size | number | 420 | 水面平面尺寸(世界单位) | + +## 渲染器支持 + +水面着色使用 TSL 编写,两种渲染器均可运行: + +- **WebGPU** 使用 `reflector()` 节点实现平面反射,使用 `viewportSharedTexture()` 实现折射,全部交由渲染图处理。 +- **WebGL** 每帧将镜像相机渲染的平面反射,以及隐藏水面后的场景折射,渲染到半浮点渲染目标,再在水面着色器中采样。 + +捕获分辨率按绘制缓冲区尺寸计算,限定在最长边的 14% 到 42% 之间,并受 `captureResolution` 缩放。当相机低于动态水面时,水面会着色其背面,并暂停平面捕获,直到相机重新回到水面之上。 diff --git a/docs/sidebars.js b/docs/sidebars.js index cc18eac..10e57e8 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -27,7 +27,7 @@ const sidebars = { type: 'category', label: 'Effect', collapsible: true, - items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/height-fog', 'effects/sweep-light', 'effects/light-gradient', 'effects/light-pillar', 'effects/fireworks'], + items: ['effects/bloom', 'effects/rain', 'effects/snow', 'effects/water', 'effects/height-fog', 'effects/sweep-light', 'effects/light-gradient', 'effects/light-pillar', 'effects/fireworks'], }, { type: 'category', diff --git a/docs/src/components/effects/Water.tsx b/docs/src/components/effects/Water.tsx new file mode 100644 index 0000000..b3f2ad5 --- /dev/null +++ b/docs/src/components/effects/Water.tsx @@ -0,0 +1,25 @@ +import * as THREE from 'three' +import { Scene, Water } from 'react-three-lite' +import type { SceneComponents } from 'react-three-lite' + +interface AppProps { + rendererType?: 'webgpu' | 'webgl' +} + +export default function App({ rendererType }: AppProps) { + const handleCreated = (scene: THREE.Scene, components: SceneComponents) => { + const { camera } = components + camera.position.set(0, 1.5, 3) + } + + return ( + + + + ) +} diff --git a/src/__tests__/Water.test.ts b/src/__tests__/Water.test.ts new file mode 100644 index 0000000..53a97df --- /dev/null +++ b/src/__tests__/Water.test.ts @@ -0,0 +1,172 @@ +import * as THREE from 'three' +import { uniform, vec3 } from 'three/tsl' +import { describe, expect, it } from 'vitest' +import { createWaterMaterial } from '../utils/WaterMaterial' +import { createNoiseTexture, NOISE_TEXTURE_SIZE } from '../utils/WaterNoise' +import { updateMirrorCamera } from '../utils/WaterReflection' +import { OCEAN_WAVES, sampleOceanSurface } from '../utils/WaterSpectrum' + +describe('WaterSpectrum', () => { + it('defines a directional spectrum with normalized wave directions', () => { + expect(OCEAN_WAVES.length).toBeGreaterThanOrEqual(14) + + OCEAN_WAVES.forEach((wave) => { + expect(wave.direction.length()).toBeCloseTo(1, 6) + expect(wave.wavelength).toBeGreaterThan(0) + expect(wave.steepness).toBeGreaterThan(0) + expect(wave.lodEnd).toBeGreaterThan(wave.lodStart) + }) + }) + + it('samples a finite surface with upward facing normals', () => { + const times = [0, 1.7, 12.5, 240] + const positions = [-210, -60, 0, 45, 209] + + times.forEach((time) => { + positions.forEach((x) => { + positions.forEach((z) => { + const sample = sampleOceanSurface(x, z, time) + + expect(Number.isFinite(sample.height)).toBe(true) + expect(Number.isFinite(sample.normal.x)).toBe(true) + expect(Number.isFinite(sample.normal.y)).toBe(true) + expect(Number.isFinite(sample.normal.z)).toBe(true) + // The surface normal always points away from the water body. + expect(sample.normal.y).toBeGreaterThan(0) + expect(sample.normal.length()).toBeCloseTo(1, 6) + }) + }) + }) + }) + + it('keeps the wave height within the summed amplitude budget', () => { + // Each mode is bounded by its amplitude and the packet envelope, so the + // surface can never exceed twice the linear amplitude sum. + const amplitudeSum = OCEAN_WAVES.reduce( + (sum, wave) => sum + (wave.steepness * wave.wavelength) / (Math.PI * 2), + 0 + ) + const limit = amplitudeSum * 2 * 1.6 + + for (let index = 0; index < 40; index += 1) { + const time = index * 0.7 + const sample = sampleOceanSurface(Math.cos(index) * 180, Math.sin(index * 1.7) * 180, time) + expect(Math.abs(sample.height)).toBeLessThan(limit) + } + }) + + it('is deterministic for identical inputs and varies with time and position', () => { + const base = sampleOceanSurface(12.5, -8.25, 3.5) + const repeated = sampleOceanSurface(12.5, -8.25, 3.5) + const laterInTime = sampleOceanSurface(12.5, -8.25, 4.5) + const elsewhere = sampleOceanSurface(25, -8.25, 3.5) + + expect(repeated.height).toBe(base.height) + expect(repeated.normal.equals(base.normal)).toBe(true) + expect(laterInTime.height).not.toBe(base.height) + expect(elsewhere.height).not.toBe(base.height) + }) +}) + +describe('WaterNoise', () => { + it('creates a repeatable grayscale noise map', () => { + const texture = createNoiseTexture() + + expect(texture.image.width).toBe(NOISE_TEXTURE_SIZE) + expect(texture.image.height).toBe(NOISE_TEXTURE_SIZE) + const data = (texture.image as { data: Uint8Array }).data + expect(data.length).toBe(NOISE_TEXTURE_SIZE * NOISE_TEXTURE_SIZE * 4) + expect(texture.wrapS).toBe(THREE.RepeatWrapping) + expect(texture.wrapT).toBe(THREE.RepeatWrapping) + expect(texture.colorSpace).toBe(THREE.NoColorSpace) + expect(texture.generateMipmaps).toBe(true) + + const uniqueBytes = new Set(Array.from(data.slice(0, 4096))) + expect(uniqueBytes.size).toBeGreaterThan(32) + + texture.dispose() + }) +}) + +describe('WaterMaterial', () => { + it('builds a water material graph from the shared spectrum', () => { + const noiseMap = createNoiseTexture() + const material = createWaterMaterial({ + timeNode: uniform(0), + underwaterNode: uniform(0), + noiseMap, + sunDirection: uniform(new THREE.Vector3(0, 1, 0)), + samplers: { + sampleReflection: () => vec3(0), + sampleRefraction: () => vec3(0) + } + }) + + expect(material.name).toBe('Water') + expect(material.positionNode).toBeDefined() + expect(material.colorNode).toBeDefined() + expect(material.side).toBe(THREE.DoubleSide) + expect(material.transparent).toBe(false) + expect(material.depthWrite).toBe(true) + + material.dispose() + noiseMap.dispose() + }) +}) + +describe('WaterReflection', () => { + const createFixture = () => { + const mirror = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshBasicMaterial()) + mirror.rotation.x = -Math.PI / 2 + mirror.updateMatrixWorld(true) + + const camera = new THREE.PerspectiveCamera(51, 1, 0.08, 520) + camera.position.set(0, 2, 5) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld(true) + + const mirrorCamera = camera.clone() + + return { mirror, camera, mirrorCamera } + } + + it('mirrors the camera across the water plane', () => { + const { mirror, camera, mirrorCamera } = createFixture() + + expect(updateMirrorCamera(camera, mirror, mirrorCamera)).toBe(true) + expect(mirrorCamera.position.x).toBeCloseTo(camera.position.x, 5) + expect(mirrorCamera.position.y).toBeCloseTo(-camera.position.y, 5) + expect(mirrorCamera.position.z).toBeCloseTo(camera.position.z, 5) + }) + + it('projects the scene into the flipped screen space the shader samples', () => { + const { mirror, camera, mirrorCamera } = createFixture() + updateMirrorCamera(camera, mirror, mirrorCamera) + + const points = [ + new THREE.Vector3(1, 1.5, -2), + new THREE.Vector3(-3, 0.8, -12), + new THREE.Vector3(4, 2.4, -30) + ] + + points.forEach((point) => { + // A mirrored point seen through the main camera lands exactly where the + // reflected camera draws the original point, with the horizontal axis + // flipped because the reflected up vector mirrors the camera basis. + const mirroredNdc = point.clone().project(mirrorCamera) + const expectedNdc = point.clone().setY(-point.y).project(camera) + + expect(mirroredNdc.x).toBeCloseTo(-expectedNdc.x, 4) + expect(mirroredNdc.y).toBeCloseTo(expectedNdc.y, 4) + }) + }) + + it('skips the capture when the plane faces away from the camera', () => { + const { mirror, camera, mirrorCamera } = createFixture() + camera.position.set(0, -1.5, 1.2) + camera.lookAt(0, 0, 0) + camera.updateMatrixWorld(true) + + expect(updateMirrorCamera(camera, mirror, mirrorCamera)).toBe(false) + }) +}) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index a825f54..bab6ac0 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -38,6 +38,7 @@ const EXPECTED_EXPORTS = [ 'TILE_SIZE', 'TileLayer', 'WaveCircleMesh', + 'Water', 'bd09ToGcj02', 'bindSceneGeo', 'collectGeometries', @@ -112,6 +113,7 @@ describe('public API surface', () => { 'SweepLight', 'TileLayer', 'WaveCircleMesh', + 'Water', 'useScene', 'disposeModel', 'disposeDRACOLoader', diff --git a/src/components/Water.tsx b/src/components/Water.tsx new file mode 100644 index 0000000..27a5319 --- /dev/null +++ b/src/components/Water.tsx @@ -0,0 +1,255 @@ +import { useEffect, useRef } from 'react' +import * as THREE from 'three' +import { + reflector, + screenUV, + sRGBTransferOETF, + texture, + uniform, + viewportSafeUV, + viewportSharedTexture +} from 'three/tsl' +import { type Node } from 'three/webgpu' +import { useScene } from '../context/SceneContext' +import { createWaterMaterial, type WaterMaterialSamplers } from '../utils/WaterMaterial' +import { createNoiseTexture } from '../utils/WaterNoise' +import { updateMirrorCamera } from '../utils/WaterReflection' +import { sampleOceanSurface } from '../utils/WaterSpectrum' + +export interface WaterProps { + /** Direction from the water surface towards the sun. */ + sunDirection?: THREE.Vector3 | [number, number, number] + /** Longest edge of the planar captures, in device pixels. */ + captureResolution?: number + /** Plane subdivisions per axis. */ + segments?: number + /** Size of the water patch, in world units. */ + size?: number +} + +const DEFAULT_SUN_DIRECTION: [number, number, number] = [-0.58, 0.1, -0.81] +// Captures are sized relative to the drawing buffer and clamped to keep the +// cost bounded on very large and very small canvases. +const MIN_CAPTURE_SCALE = 0.14 +const MAX_CAPTURE_SCALE = 0.42 +// While the camera is well below the surface the captures are not visible, so +// they are skipped and the previous results are reused. +const UNDERWATER_CAPTURE_SKIP = 0.65 +const UNDERWATER_EASING = 0.085 + +/** + * Cinematic shallow ocean surface. + * + * Renders a displaced plane driven by a 14 wave directional spectrum that is + * shared with the CPU sampler used for the underwater test. Shading includes + * Fresnel reflectance, GGX sun glitter, foam, depth based water colors and + * planar reflection / refraction of the surrounding scene. + * + * Both renderers are supported: WebGPU uses the reflector and viewport shared + * texture nodes, while the classic WebGL renderer renders the two planar + * captures into half float render targets on the CPU side. + */ +const Water = ({ + sunDirection = DEFAULT_SUN_DIRECTION, + captureResolution = 512, + segments = 240, + size = 420 +}: WaterProps) => { + const sceneContext = useScene() + const animationIdRef = useRef(null) + + const [sunX, sunY, sunZ] = Array.isArray(sunDirection) + ? sunDirection + : [sunDirection.x, sunDirection.y, sunDirection.z] + + useEffect(() => { + const { renderer, scene, sceneComponents, container } = sceneContext + if (!renderer || !scene || !sceneComponents?.camera) { + return + } + + const camera = sceneComponents.camera + const isWebGPU = renderer.isWebGPURenderer === true + const sunVector = new THREE.Vector3(sunX, sunY, sunZ).normalize() + + const noiseTexture = createNoiseTexture() + const uTime = uniform(0) + const uUnderwater = uniform(0) + const uSunDirection = uniform(sunVector) + + const geometry = new THREE.PlaneGeometry(size, size, segments, segments) + geometry.rotateX(-Math.PI / 2) + + const drawingSize = new THREE.Vector2() + + const computeCaptureScale = () => { + renderer.getDrawingBufferSize(drawingSize) + return THREE.MathUtils.clamp( + captureResolution / Math.max(drawingSize.width, drawingSize.height, 1), + MIN_CAPTURE_SCALE, + MAX_CAPTURE_SCALE + ) + } + + const computeCaptureSize = () => { + const scale = computeCaptureScale() + renderer.getDrawingBufferSize(drawingSize) + return { + width: Math.max(1, Math.round(drawingSize.width * scale)), + height: Math.max(1, Math.round(drawingSize.height * scale)) + } + } + + let reflectionSampler: ReturnType | null = null + let reflectionTarget: THREE.WebGLRenderTarget | null = null + let refractionTarget: THREE.WebGLRenderTarget | null = null + let samplers: WaterMaterialSamplers + + if (isWebGPU) { + const sampler = reflector({ bounces: false, samples: 0 }) + sampler.reflector.resolutionScale = computeCaptureScale() + // Geometry vertices are already rotated into XZ space, so the reflector + // target itself must explicitly expose a +Y plane normal. + sampler.target.rotation.x = -Math.PI / 2 + reflectionSampler = sampler + + samplers = { + sampleReflection: (distortion) => { + const baseUv = sampler.uvNode ?? screenUV.flipX() + sampler.uvNode = baseUv.add(distortion) + return sampler.rgb + }, + sampleRefraction: (offset) => + viewportSharedTexture(viewportSafeUV(screenUV.sub(offset))).rgb + } + } else { + const captureSize = computeCaptureSize() + const reflection = new THREE.WebGLRenderTarget(captureSize.width, captureSize.height, { + type: THREE.HalfFloatType, + samples: 0 + }) + const refraction = new THREE.WebGLRenderTarget(captureSize.width, captureSize.height, { + type: THREE.HalfFloatType, + samples: 0 + }) + reflectionTarget = reflection + refractionTarget = refraction + + samplers = { + sampleReflection: (distortion) => + texture(reflection.texture, screenUV.flipX().add(distortion)).rgb, + // The WebGL capture is rendered into a linear half float target, while + // the WebGPU viewport texture exposes the display encoded canvas, so + // the sample is encoded here to match both backends. + sampleRefraction: (offset) => + sRGBTransferOETF(texture(refraction.texture, screenUV.sub(offset)).rgb) as Node<'vec3'> + } + } + + const material = createWaterMaterial({ + timeNode: uTime, + underwaterNode: uUnderwater, + noiseMap: noiseTexture, + sunDirection: uSunDirection, + samplers + }) + + const mesh = new THREE.Mesh(geometry, material) + mesh.name = 'Water' + mesh.frustumCulled = false + // Draw after the rest of the scene so the viewport captures include it. + mesh.renderOrder = 5 + if (reflectionSampler) { + mesh.add(reflectionSampler.target) + } + scene.add(mesh) + + const mirrorCamera = camera.clone() + + const captureViews = () => { + if (!reflectionTarget || !refractionTarget) { + return + } + + const previousRenderTarget = renderer.getRenderTarget() + // Both captures must exclude the water surface itself. + mesh.visible = false + + renderer.setRenderTarget(refractionTarget) + renderer.clear() + renderer.render(scene, camera) + + if (updateMirrorCamera(camera, mesh, mirrorCamera)) { + renderer.setRenderTarget(reflectionTarget) + renderer.clear() + renderer.render(scene, mirrorCamera) + } + + renderer.setRenderTarget(previousRenderTarget) + mesh.visible = true + } + + let resizeObserver: ResizeObserver | undefined + if (container) { + resizeObserver = new ResizeObserver(() => { + if (isWebGPU) { + if (reflectionSampler) { + reflectionSampler.reflector.resolutionScale = computeCaptureScale() + } + return + } + const captureSize = computeCaptureSize() + reflectionTarget?.setSize(captureSize.width, captureSize.height) + refractionTarget?.setSize(captureSize.width, captureSize.height) + }) + resizeObserver.observe(container) + } + + // Use a dedicated requestAnimationFrame loop instead of addBeforeFrame to + // avoid triggering the Scene's beforeFrameSetRef render path (which skips + // renderer.clear() and background — problematic on WebGPU). The Scene + // registers its own loop first, so the captures happen after the frame + // has been rendered and are one frame behind. + const startTimestamp = performance.now() + let underwaterMix = 0 + + const animate = () => { + const elapsed = (performance.now() - startTimestamp) / 1000 + uTime.value = elapsed + + // The CPU sampler mirrors the GPU displacement, so the waterline test + // follows the same surface the shader renders. + const cameraSurface = sampleOceanSurface(camera.position.x, camera.position.z, elapsed) + const underwaterTarget = camera.position.y < cameraSurface.height ? 1 : 0 + underwaterMix = THREE.MathUtils.lerp(underwaterMix, underwaterTarget, UNDERWATER_EASING) + uUnderwater.value = underwaterMix + + if (!isWebGPU && underwaterMix < UNDERWATER_CAPTURE_SKIP) { + captureViews() + } + + animationIdRef.current = requestAnimationFrame(animate) + } + animationIdRef.current = requestAnimationFrame(animate) + + // Cleanup on unmount + return () => { + if (animationIdRef.current !== null) { + cancelAnimationFrame(animationIdRef.current) + animationIdRef.current = null + } + resizeObserver?.disconnect() + scene.remove(mesh) + geometry.dispose() + material.dispose() + noiseTexture.dispose() + reflectionSampler?.dispose() + reflectionTarget?.dispose() + refractionTarget?.dispose() + } + }, [sceneContext, sunX, sunY, sunZ, captureResolution, segments, size]) + + return null +} + +export default Water diff --git a/src/index.ts b/src/index.ts index 067a2f4..095b813 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import Bloom from './components/Bloom' import Rain from './components/Rain' import Snow from './components/Snow' import HeightFog from './components/HeightFog' +import Water from './components/Water' import GeoObject from './components/GeoObject' import GeoJsonLayer from './components/GeoJsonLayer' import TileLayer from './components/TileLayer' @@ -56,6 +57,7 @@ export { Rain, Snow, HeightFog, + Water, GeoObject, GeoJsonLayer, TileLayer, @@ -117,6 +119,7 @@ export type { GeoJsonPosition } from './crs/GeoJson' export type { GeoObjectProps } from './components/GeoObject' +export type { WaterProps } from './components/Water' export type { GeoJsonLayerProps, GeoJsonStyle } from './components/GeoJsonLayer' export type { TileLayerProps } from './components/TileLayer' export type { TileIndex, TileBounds, TileCorners } from './crs/Tiles' diff --git a/src/utils/WaterMaterial.ts b/src/utils/WaterMaterial.ts new file mode 100644 index 0000000..e70656d --- /dev/null +++ b/src/utils/WaterMaterial.ts @@ -0,0 +1,410 @@ +import * as THREE from 'three' +import { MeshBasicNodeMaterial, type Node } from 'three/webgpu' +import { + Fn, + If, + abs, + cameraPosition, + clamp, + dFdx, + dFdy, + dot, + exp, + float, + fwidth, + max, + min, + mix, + normalize, + positionWorld, + pow, + reflect, + refract, + sin, + smoothstep, + varyingProperty, + vec2, + vec3 +} from 'three/tsl' +import { + distributionGgxNode, + fbmNode, + fresnelSchlickNode, + microGradientNode, + valueNoiseNode, + visibilitySmithGgxCorrelatedNode +} from './WaterShading' +import { OCEAN_WAVES } from './WaterSpectrum' +import { createWavePositionNode, type WaveVaryings } from './WaterWaveNodes' + +const dominantWave = OCEAN_WAVES[0] + +/** + * Backend specific scene captures. The WebGPU path resolves both samplers + * through viewport/reflector nodes, while the WebGL path samples explicit + * render targets captured by the Water component. + */ +export interface WaterMaterialSamplers { + sampleReflection: (distortion: Node<'vec2'>) => Node<'vec3'> + sampleRefraction: (offset: Node<'vec2'>) => Node<'vec3'> +} + +export interface WaterMaterialOptions { + timeNode: Node<'float'> + underwaterNode: Node<'float'> + noiseMap: THREE.Texture + sunDirection: Node<'vec3'> + samplers: WaterMaterialSamplers +} + +export function createWaterMaterial({ + timeNode, + underwaterNode, + noiseMap, + sunDirection, + samplers +}: WaterMaterialOptions): MeshBasicNodeMaterial { + const material = new MeshBasicNodeMaterial() + material.name = 'Water' + material.transparent = false + material.depthWrite = true + material.side = THREE.DoubleSide + material.forceSinglePass = true + + const varyings: WaveVaryings = { + normal: varyingProperty('vec3', 'waterWaveNormal'), + surfacePosition: varyingProperty('vec2', 'waterSurfacePosition'), + waveHeight: varyingProperty('float', 'waterWaveHeight'), + waveSlope: varyingProperty('float', 'waterWaveSlope') + } + const waveNormal = varyings.normal as Node<'vec3'> + const surfacePosition = varyings.surfacePosition as Node<'vec2'> + const waveHeight = varyings.waveHeight as Node<'float'> + const waveSlope = varyings.waveSlope as Node<'float'> + material.positionNode = createWavePositionNode(timeNode, 0, varyings) + + material.colorNode = Fn(() => { + const viewDirection = normalize(cameraPosition.sub(positionWorld)) + const distanceToCamera = cameraPosition.sub(positionWorld).length() + const macroGradient = waveNormal.xz.negate().div(max(waveNormal.y, 0.24)) + const detailGradient = microGradientNode(surfacePosition, distanceToCamera, timeNode, noiseMap) + const combinedGradient = macroGradient.add(detailGradient) + const surfaceUp = normalize(vec3(combinedGradient.x.negate(), 1, combinedGradient.y.negate())) + // An interpolated normal and its exact opposite collapse to zero at a 50% + // medium blend. Keep stable normals for both sides of the interface and + // blend the shaded colors instead. + const undersideNormal = surfaceUp.negate() + const viewFacing = clamp(abs(dot(surfaceUp, viewDirection)), 0, 1) + const fresnel = float(0.025).add(pow(float(1).sub(viewFacing), 5).mul(0.975)) + const nearField = float(1).sub(smoothstep(18, 115, distanceToCamera)) + + // The medium blend eases for several frames when crossing the waterline. + // Shade both sides only during that transition; settled frames otherwise + // spend most of their fragment work on a color that was blended away. + const surfaceOutput = vec3(0).toVar() + If(underwaterNode.lessThan(0.98), () => { + const waterColumn = float(3.55).div(max(viewFacing, 0.28)) + const opticalDepth = clamp( + float(1) + .sub(exp(waterColumn.mul(-0.052))) + .add(smoothstep(100, 210, distanceToCamera).mul(0.12)), + 0, + 1 + ) + const depthVariation = fbmNode( + surfacePosition.mul(0.075).add(vec2(timeNode.mul(0.012), timeNode.mul(-0.008))), + noiseMap + ) + const secondaryVariation = fbmNode(surfacePosition.mul(0.17).add(vec2(-11, 7)), noiseMap) + const clearPatch = smoothstep( + 0.32, + 0.72, + depthVariation.mul(0.72).add(secondaryVariation.mul(0.28)) + ) + const shallowMix = clamp( + float(1).sub(opticalDepth).mul(0.42).add(clearPatch.mul(nearField).mul(0.2)), + 0, + 0.76 + ) + const waterBody = mix(vec3(0.002, 0.023, 0.037), vec3(0.008, 0.45, 0.47), shallowMix).toVar() + waterBody.mulAssign(mix(0.93, 1.03, secondaryVariation)) + + const cloudUv = surfacePosition + .mul(0.018) + .add(vec2(timeNode.mul(0.0024), timeNode.mul(0.001))) + const cloudBroad = fbmNode(cloudUv, noiseMap) + .mul(0.77) + .add(fbmNode(cloudUv.mul(2.17).sub(14.3), noiseMap).mul(0.23)) + const cloudEdge = fbmNode(cloudUv.mul(4.3).add(7.9), noiseMap) + const cloudShadow = smoothstep(0.5, 0.66, cloudBroad).mul( + mix(0.64, 1, smoothstep(0.27, 0.68, cloudEdge)) + ) + waterBody.mulAssign(mix(1, 0.72, cloudShadow.mul(float(0.42).add(nearField.mul(0.3))))) + + const facingLight = max(dot(surfaceUp, sunDirection), 0) + waterBody.addAssign(vec3(0, 0.065, 0.075).mul(pow(facingLight, 2)).mul(nearField)) + + const refractionOffset = combinedGradient.mul(mix(0.002, 0.006, viewFacing)) + const refractedScene = samplers.sampleRefraction(refractionOffset) + const transmissionTint = mix(vec3(0.58, 0.88, 0.82), vec3(0.2, 0.45, 0.52), opticalDepth) + const columnTransmittance = exp(waterColumn.mul(-0.045)) + const transmissionAmount = mix(0.4, 0.52, clearPatch) + .mul(columnTransmittance) + .mul(float(0.5).add(nearField.mul(0.5))) + const sceneTransmission = mix( + waterBody, + refractedScene.mul(transmissionTint), + clamp(transmissionAmount, 0, 0.66) + ) + + const reflectedCapture = clamp( + samplers.sampleReflection(combinedGradient.mul(mix(0.003, 0.009, viewFacing))), + vec3(0), + vec3(1) + ) + const reflectionDirection = reflect(viewDirection.negate(), surfaceUp) + const reflectedSky = mix( + vec3(0.075, 0.23, 0.43), + vec3(0.007, 0.045, 0.16), + smoothstep(0, 0.82, max(reflectionDirection.y, 0)) + ) + const reflectedScene = mix(reflectedSky, reflectedCapture, 0.48).toVar() + const reflectionAzimuth = dot( + normalize(reflectionDirection.xz.add(0.0001)), + normalize(vec2(sunDirection.x, sunDirection.z)) + ) + .mul(0.5) + .add(0.5) + reflectedScene.mulAssign( + mix( + vec3(0.3, 0.5, 0.68), + vec3(0.62, 0.76, 0.84), + smoothstep(0.15, 0.85, reflectionAzimuth) + ).mul(mix(0.74, 1, secondaryVariation)) + ) + reflectedScene.assign(mix(reflectedScene, vec3(0.002, 0.023, 0.037), 0.08)) + const facetReflection = smoothstep(0.035, 0.16, detailGradient.length()) + const reflectionMix = clamp( + float(0.085).add(fresnel.mul(0.89)).add(facetReflection.mul(nearField).mul(0.045)), + 0, + 0.97 + ) + const surfaceColor = mix(sceneTransmission, reflectedScene, reflectionMix).toVar() + surfaceColor.mulAssign(mix(1, 0.84, cloudShadow.mul(float(1).sub(fresnel)).mul(0.72))) + const localFacetLight = smoothstep(-0.06, 0.32, dot(surfaceUp, sunDirection)) + surfaceColor.mulAssign(mix(0.92, 1.055, localFacetLight.mul(nearField))) + + const distanceHue = smoothstep(24, 138, distanceToCamera) + const nearGrade = surfaceColor + .mul(vec3(0.74, 1.1, 1.06)) + .add(vec3(0, 0.032, 0.036).mul(nearField).mul(clearPatch)) + const farGrade = surfaceColor.mul(vec3(0.68, 0.84, 1.12)) + surfaceColor.assign(mix(nearGrade, farGrade, distanceHue)) + + const crestTransmission = smoothstep( + 0.12, + 0.72, + waveHeight.add(min(waveSlope, 0.42).mul(0.24)) + ) + const backLighting = pow(max(dot(viewDirection.negate(), sunDirection), 0), 2.4) + const crestRim = pow(float(1).sub(viewFacing), 1.35) + surfaceColor.addAssign( + vec3(0.008, 0.2, 0.19) + .mul(crestTransmission) + .mul(crestRim) + .mul(float(0.24).add(backLighting.mul(0.76))) + ) + + // A derivative-widened GGX lobe keeps reflected energy visible as + // individual wave facets become sub-pixel. + const halfDirection = normalize(viewDirection.add(sunDirection)) + const normalDotView = max(dot(surfaceUp, viewDirection), 0.001) + const normalDotLight = max(dot(surfaceUp, sunDirection), 0.001) + const normalDotHalf = max(dot(surfaceUp, halfDirection), 0) + const viewDotHalf = max(dot(viewDirection, halfDirection), 0) + const normalDx = dFdx(surfaceUp) + const normalDy = dFdy(surfaceUp) + const normalVariance = max(dot(normalDx, normalDx), dot(normalDy, normalDy)) + const baseRoughness = mix(0.04, 0.105, smoothstep(22, 155, distanceToCamera)) + const microfacetAlpha = clamp( + baseRoughness.mul(baseRoughness).add(min(normalVariance.mul(0.32), 0.055)), + 0.0012, + 0.052 + ) + const sunFresnel = fresnelSchlickNode(viewDotHalf) + const distribution = distributionGgxNode(microfacetAlpha, normalDotHalf) + const visibility = visibilitySmithGgxCorrelatedNode( + microfacetAlpha, + normalDotView, + normalDotLight + ) + const rawSunSpecular = distribution.mul(visibility).mul(sunFresnel).mul(normalDotLight) + const sunSpecular = pow(rawSunSpecular.div(float(1).add(rawSunSpecular)), 1.22) + + // The broad lobe locates a continuous reflection path; the advected + // multi-scale mask resolves it into wind-oriented sparkles. + const grazingAmount = float(1).sub(smoothstep(1.2, 3, max(cameraPosition.y, 0))) + const broadAlpha = clamp( + microfacetAlpha.mul(mix(2.7, 3.7, grazingAmount)).add(mix(0.01, 0.016, grazingAmount)), + 0.014, + mix(0.088, 0.122, grazingAmount) + ) + const broadDistribution = distributionGgxNode(broadAlpha, normalDotHalf) + const broadVisibility = visibilitySmithGgxCorrelatedNode( + broadAlpha, + normalDotView, + normalDotLight + ) + const rawBroadSpecular = broadDistribution + .mul(broadVisibility) + .mul(sunFresnel) + .mul(normalDotLight) + const broadSpecular = rawBroadSpecular.div(float(1).add(rawBroadSpecular)) + + const glitterWind = vec2(dominantWave.direction.x, dominantWave.direction.y) + const glitterCross = vec2(-dominantWave.direction.y, dominantWave.direction.x) + const glitterPosition = surfacePosition + .sub(glitterWind.mul(timeNode.mul(0.31))) + .add(glitterCross.mul(timeNode.mul(0.047))) + .add(combinedGradient.mul(0.16)) + const glitterUv = vec2( + dot(glitterPosition, glitterWind).mul(0.72), + dot(glitterPosition, glitterCross).mul(1.58) + ) + // GLSL matrices are column-major. This mirrors mat2(0.61, -0.79, 0.79, + // 0.61) * glitterUv. + const turnedGlitterUv = vec2( + glitterUv.x.mul(0.61).add(glitterUv.y.mul(0.79)), + glitterUv.x.mul(-0.79).add(glitterUv.y.mul(0.61)) + ) + const glitterNoise = valueNoiseNode(glitterUv.mul(2.5).add(vec2(7.3, -4.8)), noiseMap) + .mul(0.26) + .add(valueNoiseNode(turnedGlitterUv.mul(8.8).add(vec2(-13.2, 19.6)), noiseMap).mul(0.47)) + .add(valueNoiseNode(turnedGlitterUv.mul(21.5).add(vec2(31.7, -9.1)), noiseMap).mul(0.27)) + const glitterAa = max(fwidth(glitterNoise).mul(1.45), 0.022) + const resolvedOccupancy = smoothstep( + float(0.5).sub(glitterAa), + float(0.69).add(glitterAa), + glitterNoise + ) + const glitterSparkles = smoothstep( + float(0.67).sub(glitterAa), + float(0.83).add(glitterAa), + glitterNoise + ) + const resolvedGlitter = float(1).sub(smoothstep(62, 175, distanceToCamera)) + const glitterOccupancy = mix(0.18, resolvedOccupancy, resolvedGlitter) + const glitterEnergy = sunSpecular + .mul(mix(0.04, 0.92, glitterOccupancy)) + .add(broadSpecular.mul(glitterOccupancy.mul(0.28).add(glitterSparkles.mul(0.72))).mul(0.92)) + // Key the sub-pixel solar facet compensation to camera height (not + // per-fragment NdotV) so medium and top-down views use the unmodified + // shared BRDF. + const grazingResolutionCompensation = mix(1, 4.2, grazingAmount) + surfaceColor.addAssign( + vec3(1, 0.84, 0.61).mul(glitterEnergy).mul(2.85).mul(grazingResolutionCompensation) + ) + + const advectedFoam = surfacePosition + .sub(glitterWind.mul(timeNode.mul(dominantWave.speed))) + .sub(glitterCross.mul(timeNode.mul(0.026))) + const foamAlong = dot(advectedFoam, glitterWind) + const foamAcross = dot(advectedFoam, glitterCross) + const breakingZoneField = float(0.5) + .add(sin(foamAlong.mul(0.024).add(foamAcross.mul(0.01)).add(4.3)).mul(0.24)) + .add(sin(foamAlong.mul(0.047).sub(foamAcross.mul(0.017)).sub(11.6)).mul(0.15)) + .add(sin(foamAlong.mul(0.013).add(foamAcross.mul(0.029)).add(17.2)).mul(0.11)) + const breakingZone = smoothstep(0.36, 0.64, breakingZoneField) + const foamUv = vec2(foamAlong.mul(1.55), foamAcross.mul(0.72)) + const foamContour = fbmNode(foamUv.mul(2.15).add(vec2(3.4, -6.2)), noiseMap) + const filaments = float(1).sub(smoothstep(0.018, 0.064, abs(foamContour.sub(0.565)))) + const tornMask = smoothstep( + 0.37, + 0.62, + fbmNode( + vec2( + foamUv.x.mul(0.73).sub(foamUv.y.mul(0.68)), + foamUv.x.mul(0.68).add(foamUv.y.mul(0.73)) + ) + .mul(4.15) + .add(vec2(-7.6, 2.8)), + noiseMap + ) + ) + const foamMicro = valueNoiseNode(foamUv.mul(13.8).add(vec2(19.4, -11.7)), noiseMap) + const porousRibbon = max( + filaments.mul(tornMask).mul(smoothstep(0.32, 0.58, foamMicro)), + smoothstep(0.78, 0.91, foamMicro).mul(0.16) + ) + const foamThreshold = mix( + 0.075, + 0.17, + valueNoiseNode(advectedFoam.mul(0.21).add(vec2(8.7, -4.1)), noiseMap) + ) + const crestSignal = waveHeight.add(min(waveSlope, 0.34).mul(0.18)) + const breakingEnergy = smoothstep( + foamThreshold.sub(0.15), + foamThreshold.add(0.21), + crestSignal + ) + const foam = breakingEnergy + .mul(mix(breakingEnergy, 1, 0.22)) + .mul(mix(0.22, 1.35, breakingZone)) + .mul(porousRibbon) + .mul(float(1).sub(smoothstep(105, 220, distanceToCamera).mul(0.36))) + surfaceColor.assign( + mix(surfaceColor, vec3(0.76, 0.87, 0.85), foam.mul(mix(0.34, 0.6, viewFacing))) + ) + + const horizonFade = smoothstep(105, 205, distanceToCamera) + const horizonAbsorption = mix(0.78, 0.52, smoothstep(0.15, 0.85, reflectionAzimuth)) + surfaceColor.assign( + mix(surfaceColor, vec3(0.043, 0.204, 0.298), horizonFade.mul(horizonAbsorption)) + ) + surfaceOutput.assign(surfaceColor) + }) + + const underwaterOutput = vec3(0).toVar() + If(underwaterNode.greaterThan(0.02), () => { + const ceilingTexture = smoothstep( + 0.42, + 0.83, + fbmNode( + surfacePosition.mul(0.46).add(vec2(timeNode.mul(0.045), timeNode.mul(-0.032))), + noiseMap + ) + ) + const underwaterColor = mix( + vec3(0.0015, 0.035, 0.06), + vec3(0.008, 0.22, 0.27), + viewFacing.mul(0.72).add(0.22) + ) + .add(vec3(0.025, 0.16, 0.17).mul(ceilingTexture).mul(viewFacing)) + .toVar() + const transmissionDirection = refract(viewDirection.negate(), undersideNormal, 1.333) + const transmissionAvailable = smoothstep(0.001, 0.08, transmissionDirection.length()) + const transmissionSky = mix( + vec3(0.075, 0.23, 0.43), + vec3(0.007, 0.045, 0.16), + smoothstep(0, 0.82, max(transmissionDirection.y, 0)) + ) + underwaterColor.assign( + mix( + underwaterColor, + transmissionSky.mul(vec3(0.42, 0.78, 0.75)), + transmissionAvailable.mul(viewFacing).mul(0.48) + ) + ) + const underwaterFog = float(1).sub(exp(distanceToCamera.mul(-0.034))) + underwaterColor.assign( + mix(underwaterColor, vec3(0.0015, 0.035, 0.06), underwaterFog.mul(0.7)) + ) + underwaterOutput.assign(underwaterColor) + }) + + const mediumBlend = clamp(underwaterNode.sub(0.02).div(0.96), 0, 1) + return mix(surfaceOutput, underwaterOutput, mediumBlend) + })() + + return material +} diff --git a/src/utils/WaterNoise.ts b/src/utils/WaterNoise.ts new file mode 100644 index 0000000..74b9919 --- /dev/null +++ b/src/utils/WaterNoise.ts @@ -0,0 +1,43 @@ +import * as THREE from 'three' + +export const NOISE_TEXTURE_SIZE = 512 + +function nextRandom(state: number): number { + let value = state >>> 0 + value ^= value << 13 + value ^= value >>> 17 + value ^= value << 5 + return value >>> 0 +} + +export function createNoiseTexture(): THREE.DataTexture { + const texelCount = NOISE_TEXTURE_SIZE * NOISE_TEXTURE_SIZE + const data = new Uint8Array(texelCount * 4) + let state = 0x9e3779b9 + + for (let index = 0; index < texelCount; index += 1) { + state = nextRandom(state) + const offset = index * 4 + data[offset] = state & 0xff + data[offset + 1] = (state >>> 8) & 0xff + data[offset + 2] = (state >>> 16) & 0xff + data[offset + 3] = 255 + } + + const texture = new THREE.DataTexture( + data, + NOISE_TEXTURE_SIZE, + NOISE_TEXTURE_SIZE, + THREE.RGBAFormat, + THREE.UnsignedByteType + ) + texture.name = 'Ocean deterministic noise' + texture.wrapS = THREE.RepeatWrapping + texture.wrapT = THREE.RepeatWrapping + texture.magFilter = THREE.LinearFilter + texture.minFilter = THREE.LinearMipmapLinearFilter + texture.generateMipmaps = true + texture.colorSpace = THREE.NoColorSpace + texture.needsUpdate = true + return texture +} diff --git a/src/utils/WaterReflection.ts b/src/utils/WaterReflection.ts new file mode 100644 index 0000000..3ee1601 --- /dev/null +++ b/src/utils/WaterReflection.ts @@ -0,0 +1,84 @@ +import * as THREE from 'three' + +const mirrorPlane = new THREE.Plane() +const mirrorNormal = new THREE.Vector3() +const mirrorWorldPosition = new THREE.Vector3() +const cameraWorldPosition = new THREE.Vector3() +const cameraRotation = new THREE.Matrix4() +const lookAtPosition = new THREE.Vector3() +const clipPlane = new THREE.Vector4() +const mirrorView = new THREE.Vector3() +const mirrorTarget = new THREE.Vector3() +const clipPlaneCorner = new THREE.Vector4() + +/** + * Builds the mirrored camera used for the WebGL planar reflection capture. + * Port of the classic Reflector mirror math with an oblique near plane so + * geometry below the water plane does not leak into the reflection. + * + * The reflected camera keeps the original up vector reflected as well, which + * mirrors its horizontal axis. The reflection texture is therefore sampled + * with `screenUV.flipX()`, matching the WebGPU reflector node. + * + * Returns false when the water plane faces away from the camera, in which + * case the previous capture should be kept. + */ +export function updateMirrorCamera( + camera: THREE.PerspectiveCamera, + mirror: THREE.Mesh, + mirrorCamera: THREE.PerspectiveCamera +): boolean { + mirrorWorldPosition.setFromMatrixPosition(mirror.matrixWorld) + cameraWorldPosition.setFromMatrixPosition(camera.matrixWorld) + cameraRotation.extractRotation(mirror.matrixWorld) + mirrorNormal.set(0, 0, 1).applyMatrix4(cameraRotation) + + mirrorView.subVectors(mirrorWorldPosition, cameraWorldPosition) + if (mirrorView.dot(mirrorNormal) > 0) { + return false + } + + mirrorView.reflect(mirrorNormal).negate() + mirrorView.add(mirrorWorldPosition) + + cameraRotation.extractRotation(camera.matrixWorld) + lookAtPosition.set(0, 0, -1).applyMatrix4(cameraRotation).add(cameraWorldPosition) + mirrorTarget + .subVectors(mirrorWorldPosition, lookAtPosition) + .reflect(mirrorNormal) + .negate() + .add(mirrorWorldPosition) + + mirrorCamera.position.copy(mirrorView) + mirrorCamera.up.set(0, 1, 0).applyMatrix4(cameraRotation).reflect(mirrorNormal) + mirrorCamera.lookAt(mirrorTarget) + mirrorCamera.far = camera.far + mirrorCamera.updateMatrixWorld() + mirrorCamera.projectionMatrix.copy(camera.projectionMatrix) + + // Oblique near plane: http://www.terathon.com/code/oblique.html + mirrorPlane.setFromNormalAndCoplanarPoint(mirrorNormal, mirrorWorldPosition) + mirrorPlane.applyMatrix4(mirrorCamera.matrixWorldInverse) + clipPlane.set( + mirrorPlane.normal.x, + mirrorPlane.normal.y, + mirrorPlane.normal.z, + mirrorPlane.constant + ) + + const projectionMatrix = mirrorCamera.projectionMatrix + const elements = projectionMatrix.elements + clipPlaneCorner.x = (Math.sign(clipPlane.x) + elements[8]) / elements[0] + clipPlaneCorner.y = (Math.sign(clipPlane.y) + elements[9]) / elements[5] + clipPlaneCorner.z = -1 + clipPlaneCorner.w = (1 + elements[10]) / elements[14] + clipPlane.multiplyScalar(1 / clipPlane.dot(clipPlaneCorner)) + + elements[2] = clipPlane.x + elements[6] = clipPlane.y + elements[10] = clipPlane.z + 1 + elements[14] = clipPlane.w + mirrorCamera.projectionMatrixInverse.copy(projectionMatrix).invert() + + return true +} diff --git a/src/utils/WaterShading.ts b/src/utils/WaterShading.ts new file mode 100644 index 0000000..02e52cf --- /dev/null +++ b/src/utils/WaterShading.ts @@ -0,0 +1,183 @@ +import * as THREE from 'three' +import { + cos, + dFdx, + dFdy, + dot, + float, + floor, + fract, + max, + pow, + sqrt, + smoothstep, + texture, + vec2 +} from 'three/tsl' +import { type Node } from 'three/webgpu' +import { NOISE_TEXTURE_SIZE } from './WaterNoise' + +function turn(position: Node<'vec2'>, scale = 2.04, offset = 9.2): Node<'vec2'> { + // GLSL's mat2 constructor is column-major. Keep this orientation identical + // to mat2(0.80, -0.60, 0.60, 0.80) * position in the WebGL shader. + return vec2( + position.x.mul(0.8).add(position.y.mul(0.6)), + position.x.mul(-0.6).add(position.y.mul(0.8)) + ) + .mul(scale) + .add(vec2(offset)) +} + +// dFdx/dFdy are declared for vector nodes only, while scalar screen-space +// derivatives are valid in both GLSL and WGSL. +const scalarDx = (value: Node<'float'>): Node<'float'> => + dFdx(value as unknown as Node<'vec2'>) as unknown as Node<'float'> +const scalarDy = (value: Node<'float'>): Node<'float'> => + dFdy(value as unknown as Node<'vec2'>) as unknown as Node<'float'> + +export function valueNoiseNode(position: Node<'vec2'>, noiseMap: THREE.Texture): Node<'float'> { + const lattice = floor(position) + const fractional = fract(position) + const smoothFraction = fractional.mul(fractional).mul(float(3).sub(fractional.mul(2))) + const noiseUv = lattice.add(smoothFraction).add(0.5).div(NOISE_TEXTURE_SIZE) + return texture(noiseMap, noiseUv).r +} + +export function fbmNode(position: Node<'vec2'>, noiseMap: THREE.Texture): Node<'float'> { + let samplePosition = position + let value = valueNoiseNode(samplePosition, noiseMap).mul(0.5) + samplePosition = turn(samplePosition) + value = value.add(valueNoiseNode(samplePosition, noiseMap).mul(0.25)) + samplePosition = turn(samplePosition) + value = value.add(valueNoiseNode(samplePosition, noiseMap).mul(0.125)) + samplePosition = turn(samplePosition) + return value.add(valueNoiseNode(samplePosition, noiseMap).mul(0.0625)) +} + +export function distributionGgxNode( + alpha: Node<'float'>, + normalDotHalf: Node<'float'> +): Node<'float'> { + const alphaSquared = alpha.mul(alpha) + const denominator = normalDotHalf.mul(normalDotHalf).mul(alphaSquared.sub(1)).add(1) + return alphaSquared.div(max(denominator.mul(denominator).mul(Math.PI), 0.00001)) +} + +export function visibilitySmithGgxCorrelatedNode( + alpha: Node<'float'>, + normalDotView: Node<'float'>, + normalDotLight: Node<'float'> +): Node<'float'> { + const alphaSquared = alpha.mul(alpha) + const oneMinusAlphaSquared = float(1).sub(alphaSquared) + const viewTerm = normalDotLight.mul( + sqrt(alphaSquared.add(oneMinusAlphaSquared.mul(normalDotView).mul(normalDotView))) + ) + const lightTerm = normalDotView.mul( + sqrt(alphaSquared.add(oneMinusAlphaSquared.mul(normalDotLight).mul(normalDotLight))) + ) + return float(0.5).div(max(viewTerm.add(lightTerm), 0.00001)) +} + +export function fresnelSchlickNode(viewDotHalf: Node<'float'>): Node<'float'> { + const waterF0 = 0.02037 + return float(waterF0).add(pow(float(1).sub(viewDotHalf), 5).mul(1 - waterF0)) +} + +function rippleGradient( + position: Node<'vec2'>, + direction: [number, number], + frequency: number, + amplitude: number, + speed: number, + timeNode: Node<'float'> +): Node<'vec2'> { + const phase = dot(position, vec2(direction[0], direction[1])) + .mul(frequency) + .add(timeNode.mul(speed)) + return vec2(direction[0], direction[1]) + .mul(amplitude * frequency) + .mul(cos(phase)) +} + +function microHeightNode( + position: Node<'vec2'>, + timeNode: Node<'float'>, + noiseMap: THREE.Texture +): Node<'float'> { + const turned = vec2( + position.x.mul(0.78).add(position.y.mul(0.63)), + position.x.mul(-0.63).add(position.y.mul(0.78)) + ) + const counterTurned = vec2( + position.x.mul(0.58).sub(position.y.mul(0.81)), + position.x.mul(0.81).add(position.y.mul(0.58)) + ) + const drift = vec2(timeNode.mul(0.068), timeNode.mul(-0.047)) + const broad = valueNoiseNode(position.mul(1.65).add(drift), noiseMap) + const middle = valueNoiseNode(turned.mul(3.7).sub(drift.mul(1.37)).add(vec2(13.7)), noiseMap) + const detail = valueNoiseNode(turned.mul(7.9).add(drift.mul(1.74)).sub(vec2(8.4)), noiseMap) + const fine = valueNoiseNode(counterTurned.mul(15.8).sub(drift.mul(2.1)).add(vec2(31.6)), noiseMap) + const sparkle = valueNoiseNode(turned.mul(31).add(drift.mul(2.8)).sub(vec2(21.9)), noiseMap) + return broad + .mul(0.03) + .add(middle.mul(0.015)) + .add(detail.mul(0.0065)) + .add(fine.mul(0.0026)) + .add(sparkle.mul(0.0009)) +} + +export function microGradientNode( + surfacePosition: Node<'vec2'>, + distanceToCamera: Node<'float'>, + timeNode: Node<'float'>, + noiseMap: THREE.Texture +): Node<'vec2'> { + const warp = vec2( + fbmNode( + surfacePosition.mul(0.16).add(vec2(timeNode.mul(0.018), timeNode.mul(-0.011))), + noiseMap + ), + fbmNode( + surfacePosition + .mul(0.16) + .add(vec2(17.4, -9.2)) + .add(vec2(timeNode.mul(-0.013), timeNode.mul(0.016))), + noiseMap + ) + ).sub(0.5) + const position = surfacePosition.add(warp.mul(1.55)) + const coarse = rippleGradient(position, [0.86, 0.51], 1.15, 0.024, -0.78, timeNode).add( + rippleGradient(position, [-0.52, 0.85], 1.78, 0.0155, 1.02, timeNode) + ) + const medium = rippleGradient(position, [0.97, -0.24], 2.75, 0.0095, -1.46, timeNode).add( + rippleGradient(position, [-0.31, 0.95], 4.65, 0.0053, 1.88, timeNode) + ) + const fine = rippleGradient(position, [0.18, -0.98], 7.9, 0.00225, -2.43, timeNode).add( + rippleGradient(position, [0.68, 0.73], 13.2, 0.00082, 3.1, timeNode) + ) + const coarseFade = float(1).sub(smoothstep(120, 285, distanceToCamera)) + const mediumFade = float(1).sub(smoothstep(55, 175, distanceToCamera)) + const fineDistanceFade = float(1).sub(smoothstep(20, 92, distanceToCamera)) + const footprint = max(dFdx(position).length(), dFdy(position).length()) + const fineFootprintFade = float(1).sub(smoothstep(0.025, 0.19, footprint)) + // Recover the world-space height gradient from screen-space derivatives. + // This evaluates the five-sample micro height once instead of four times + // for central differences, removing 15 texture reads per water fragment. + const positionDx = dFdx(position) + const positionDy = dFdy(position) + const microHeight = microHeightNode(position, timeNode, noiseMap) + const heightDx = scalarDx(microHeight) + const heightDy = scalarDy(microHeight) + const determinant = positionDx.x.mul(positionDy.y).sub(positionDx.y.mul(positionDy.x)) + const inverseDeterminant = determinant.div(max(determinant.mul(determinant), 0.00000001)) + const noiseGradient = vec2( + heightDx.mul(positionDy.y).sub(positionDx.y.mul(heightDy)), + positionDx.x.mul(heightDy).sub(heightDx.mul(positionDy.x)) + ).mul(inverseDeterminant) + return coarse + .mul(coarseFade) + .add(medium.mul(mediumFade)) + .add(fine.mul(fineDistanceFade).mul(fineFootprintFade)) + .add(noiseGradient.mul(mediumFade).mul(fineFootprintFade)) +} diff --git a/src/utils/WaterSpectrum.ts b/src/utils/WaterSpectrum.ts new file mode 100644 index 0000000..682523d --- /dev/null +++ b/src/utils/WaterSpectrum.ts @@ -0,0 +1,301 @@ +import * as THREE from 'three' + +export interface OceanWave { + direction: THREE.Vector2 + steepness: number + wavelength: number + speed: number + phase: number + bendFrequency: number + bendStrength: number + packetFrequency: number + packetStrength: number + crestSharpness: number + lodStart: number + lodEnd: number +} + +export interface OceanDomainWarp { + waveVector: THREE.Vector2 + displacement: THREE.Vector2 + speed: number + phase: number +} + +export interface OceanDomainVariant { + phaseBias: number + rotation: number +} + +export interface OceanEnergyWave { + waveVector: THREE.Vector2 + amplitude: number + speed: number + phase: number +} + +export interface OceanSurfaceSample { + height: number + normal: THREE.Vector3 +} + +interface OceanDomainSample { + warpedX: number + warpedZ: number + derivativeXX: number + derivativeXZ: number + derivativeZX: number + derivativeZZ: number + energy: number + energyGradientX: number + energyGradientZ: number +} + +const createWave = ( + direction: [number, number], + steepness: number, + wavelength: number, + speed: number, + phase: number, + bendFrequency: number, + bendStrength: number, + packetFrequency: number, + packetStrength: number, + crestSharpness: number, + lodStart: number, + lodEnd: number +): OceanWave => ({ + direction: new THREE.Vector2(direction[0], direction[1]).normalize(), + steepness, + wavelength, + speed, + phase, + bendFrequency, + bendStrength, + packetFrequency, + packetStrength, + crestSharpness, + lodStart, + lodEnd +}) + +const createDomainWarp = ( + waveVector: [number, number], + displacement: [number, number], + speed: number, + phase: number +): OceanDomainWarp => ({ + waveVector: new THREE.Vector2(waveVector[0], waveVector[1]), + displacement: new THREE.Vector2(displacement[0], displacement[1]), + speed, + phase +}) + +const createEnergyWave = ( + waveVector: [number, number], + amplitude: number, + speed: number, + phase: number +): OceanEnergyWave => ({ + waveVector: new THREE.Vector2(waveVector[0], waveVector[1]), + amplitude, + speed, + phase +}) + +// Very long, non-commensurate modes bend the coordinate field rather than +// adding another visible sinusoid. They curve and split swell systems over +// hundreds of metres without changing the local crest scale. +export const OCEAN_DOMAIN_WARP: OceanDomainWarp[] = [ + createDomainWarp([0.0147, 0.0091], [5.2, -2.1], 0.011, 0.7), + createDomainWarp([-0.0109, 0.0183], [-2.8, 4.6], -0.008, 3.1), + createDomainWarp([0.0287, -0.0211], [1.9, 2.2], 0.006, 5.4) +] + +export const OCEAN_DOMAIN_VARIANTS: OceanDomainVariant[] = [{ phaseBias: 0.0, rotation: 0.0 }] + +// Broad wind-energy zones make some crest systems strengthen while others +// terminate. The frequencies deliberately share no common tile period. +export const OCEAN_ENERGY_WAVES: OceanEnergyWave[] = [ + createEnergyWave([0.0213, -0.0141], 0.22, 0.01, 0.8), + createEnergyWave([-0.0137, 0.0269], 0.15, -0.008, 2.7), + createEnergyWave([0.0391, 0.0187], 0.1, 0.006, 5.2) +] + +// A broad, directional spectrum avoids the perfectly parallel Gerstner rows +// that become obvious from high camera positions. Slow cross-wave bending and +// wave packets make crests curve, merge, and dissipate over the 420 m patch. +// The same data is injected into the GPU shader and sampled here on the CPU. +export const OCEAN_WAVES: OceanWave[] = [ + // Split the swell energy across an irregular directional band. One strong + // 24 m component made the whole 420 m patch correlate with itself every + // wavelength when viewed from above. + createWave([1.0, 0.14], 0.044, 25.7, 1.95, 0.3, 0.029, 2.7, 0.018, 0.52, 0.18, 250, 440), + createWave([0.91, -0.42], 0.038, 31.3, 2.08, 4.17, 0.023, 3.1, 0.014, 0.48, 0.16, 260, 450), + createWave([0.72, 0.69], 0.04, 28.1, 2.01, 1.83, 0.027, 2.45, 0.017, 0.46, 0.15, 240, 430), + createWave([0.97, 0.25], 0.04, 22.9, 1.83, 5.22, 0.035, 2.05, 0.022, 0.44, 0.17, 210, 400), + createWave([0.8, -0.6], 0.045, 20.7, 1.75, 2.78, 0.039, 1.85, 0.025, 0.42, 0.16, 190, 380), + createWave([0.42, 0.91], 0.035, 18.9, 1.68, 5.4, 0.043, 1.55, 0.029, 0.38, 0.12, 170, 350), + createWave([0.83, 0.56], 0.052, 16.0, 1.56, 2.1, 0.052, 1.45, 0.034, 0.4, 0.18, 170, 340), + createWave([0.98, -0.18], 0.038, 13.4, 1.43, 3.2, 0.062, 0.8, 0.044, 0.34, 0.1, 120, 260), + createWave([0.91, -0.42], 0.07, 11.8, 1.34, 4.75, 0.071, 0.92, 0.049, 0.36, 0.16, 120, 270), + createWave([0.51, -0.86], 0.036, 9.3, 1.2, 0.7, 0.09, 0.55, 0.064, 0.3, 0.08, 75, 190), + createWave([0.58, 0.82], 0.068, 8.7, 1.16, 1.25, 0.096, 0.62, 0.068, 0.32, 0.15, 70, 190), + createWave([0.69, -0.72], 0.055, 6.4, 0.98, 3.6, 0.132, 0.42, 0.091, 0.3, 0.11, 48, 150), + createWave([0.97, 0.25], 0.043, 4.8, 0.83, 5.55, 0.176, 0.29, 0.122, 0.26, 0.08, 34, 116), + createWave([0.43, -0.9], 0.03, 3.5, 0.69, 0.82, 0.238, 0.2, 0.164, 0.22, 0.05, 25, 86) +] + +function sampleOceanDomain( + x: number, + z: number, + time: number, + variant: OceanDomainVariant +): OceanDomainSample { + let warpedX = x + let warpedZ = z + let derivativeXX = 1 + let derivativeXZ = 0 + let derivativeZX = 0 + let derivativeZZ = 1 + + const cosineTurn = Math.cos(variant.rotation) + const sineTurn = Math.sin(variant.rotation) + + for (const [modeIndex, mode] of OCEAN_DOMAIN_WARP.entries()) { + const displacementX = cosineTurn * mode.displacement.x - sineTurn * mode.displacement.y + const displacementZ = sineTurn * mode.displacement.x + cosineTurn * mode.displacement.y + const phase = + mode.waveVector.x * x + + mode.waveVector.y * z - + time * mode.speed + + mode.phase + + variant.phaseBias * (0.73 + modeIndex * 0.58) + const sine = Math.sin(phase) + const cosine = Math.cos(phase) + warpedX += displacementX * sine + warpedZ += displacementZ * sine + derivativeXX += displacementX * cosine * mode.waveVector.x + derivativeXZ += displacementX * cosine * mode.waveVector.y + derivativeZX += displacementZ * cosine * mode.waveVector.x + derivativeZZ += displacementZ * cosine * mode.waveVector.y + } + + let energy = 0.82 + let energyGradientX = 0 + let energyGradientZ = 0 + for (const [modeIndex, mode] of OCEAN_ENERGY_WAVES.entries()) { + const phase = + mode.waveVector.x * x + + mode.waveVector.y * z - + time * mode.speed + + mode.phase + + variant.phaseBias * (1.11 + modeIndex * 0.47) + const cosine = Math.cos(phase) + energy += mode.amplitude * Math.sin(phase) + energyGradientX += mode.amplitude * cosine * mode.waveVector.x + energyGradientZ += mode.amplitude * cosine * mode.waveVector.y + } + + return { + warpedX, + warpedZ, + derivativeXX, + derivativeXZ, + derivativeZX, + derivativeZZ, + energy, + energyGradientX, + energyGradientZ + } +} + +export function sampleOceanSurface(x: number, z: number, time: number): OceanSurfaceSample { + let height = 0 + let gradientX = 0 + let gradientZ = 0 + const domains = OCEAN_DOMAIN_VARIANTS.map((variant) => sampleOceanDomain(x, z, time, variant)) + + for (const [waveIndex, waveDefinition] of OCEAN_WAVES.entries()) { + const domain = domains[waveIndex % domains.length] + const { direction } = waveDefinition + const perpendicularX = -direction.y + const perpendicularZ = direction.x + const along = direction.x * domain.warpedX + direction.y * domain.warpedZ + const across = perpendicularX * domain.warpedX + perpendicularZ * domain.warpedZ + const alongGradientX = direction.x * domain.derivativeXX + direction.y * domain.derivativeZX + const alongGradientZ = direction.x * domain.derivativeXZ + direction.y * domain.derivativeZZ + const acrossGradientX = + perpendicularX * domain.derivativeXX + perpendicularZ * domain.derivativeZX + const acrossGradientZ = + perpendicularX * domain.derivativeXZ + perpendicularZ * domain.derivativeZZ + + const bendPhase = + across * waveDefinition.bendFrequency + waveDefinition.phase * 1.71 - time * 0.055 + const secondaryBendPhase = + across * waveDefinition.bendFrequency * 2.13 - waveDefinition.phase * 0.73 + time * 0.035 + const bend = + (Math.sin(bendPhase) + Math.sin(secondaryBendPhase) * 0.27) * waveDefinition.bendStrength + + const packetPhase = + (along * 0.34 + across) * waveDefinition.packetFrequency + waveDefinition.phase * 2.07 + const secondaryPacketPhase = + (along * -0.18 + across * 1.83) * waveDefinition.packetFrequency - waveDefinition.phase * 1.31 + const packetEnvelope = + 1 + + waveDefinition.packetStrength * + (Math.sin(packetPhase) * 0.68 + Math.sin(secondaryPacketPhase) * 0.32) + const envelope = packetEnvelope * domain.energy + + const waveNumber = (Math.PI * 2) / waveDefinition.wavelength + const amplitude = waveDefinition.steepness / waveNumber + const currentPhase = + waveNumber * (along + bend - waveDefinition.speed * time) + waveDefinition.phase + const sine = Math.sin(currentPhase) + const cosine = Math.cos(currentPhase) + const shapedHeight = sine - waveDefinition.crestSharpness * Math.cos(currentPhase * 2) + const shapedDerivative = cosine + waveDefinition.crestSharpness * 2 * Math.sin(currentPhase * 2) + + const bendDerivative = + (Math.cos(bendPhase) * waveDefinition.bendFrequency + + Math.cos(secondaryBendPhase) * waveDefinition.bendFrequency * 2.13 * 0.27) * + waveDefinition.bendStrength + const phaseGradientX = waveNumber * (alongGradientX + acrossGradientX * bendDerivative) + const phaseGradientZ = waveNumber * (alongGradientZ + acrossGradientZ * bendDerivative) + const packetGradientX = + waveDefinition.packetStrength * + (Math.cos(packetPhase) * + 0.68 * + waveDefinition.packetFrequency * + (alongGradientX * 0.34 + acrossGradientX) + + Math.cos(secondaryPacketPhase) * + 0.32 * + waveDefinition.packetFrequency * + (alongGradientX * -0.18 + acrossGradientX * 1.83)) + const packetGradientZ = + waveDefinition.packetStrength * + (Math.cos(packetPhase) * + 0.68 * + waveDefinition.packetFrequency * + (alongGradientZ * 0.34 + acrossGradientZ) + + Math.cos(secondaryPacketPhase) * + 0.32 * + waveDefinition.packetFrequency * + (alongGradientZ * -0.18 + acrossGradientZ * 1.83)) + const envelopeGradientX = + packetGradientX * domain.energy + packetEnvelope * domain.energyGradientX + const envelopeGradientZ = + packetGradientZ * domain.energy + packetEnvelope * domain.energyGradientZ + + height += amplitude * envelope * shapedHeight + gradientX += + amplitude * (envelopeGradientX * shapedHeight + envelope * shapedDerivative * phaseGradientX) + gradientZ += + amplitude * (envelopeGradientZ * shapedHeight + envelope * shapedDerivative * phaseGradientZ) + } + + return { + height, + normal: new THREE.Vector3(-gradientX, 1, -gradientZ).normalize() + } +} diff --git a/src/utils/WaterWaveNodes.ts b/src/utils/WaterWaveNodes.ts new file mode 100644 index 0000000..e8e3c48 --- /dev/null +++ b/src/utils/WaterWaveNodes.ts @@ -0,0 +1,184 @@ +import { + Fn, + cameraPosition, + cos, + dot, + float, + normalize, + positionLocal, + sin, + smoothstep, + vec2, + vec3 +} from 'three/tsl' +import { type Node } from 'three/webgpu' +import { + OCEAN_DOMAIN_VARIANTS, + OCEAN_DOMAIN_WARP, + OCEAN_ENERGY_WAVES, + OCEAN_WAVES, + type OceanDomainVariant +} from './WaterSpectrum' + +interface OceanDomainNodes { + warpedPosition: Node<'vec2'> + derivativeX: Node<'vec2'> + derivativeZ: Node<'vec2'> + energy: Node<'float'> + energyGradient: Node<'vec2'> +} + +export interface WaveVaryings { + normal?: Node<'vec3'> + surfacePosition?: Node<'vec2'> + waveHeight?: Node<'float'> + waveSlope?: Node<'float'> +} + +function sampleOceanDomain( + basePosition: Node<'vec2'>, + timeNode: Node<'float'>, + variant: OceanDomainVariant +): OceanDomainNodes { + const cosine = Math.cos(variant.rotation) + const sine = Math.sin(variant.rotation) + const warpedPosition = basePosition.toVar() + const derivativeX = vec2(1, 0).toVar() + const derivativeZ = vec2(0, 1).toVar() + + for (let index = 0; index < OCEAN_DOMAIN_WARP.length; index += 1) { + const mode = OCEAN_DOMAIN_WARP[index] + const displacementX = cosine * mode.displacement.x - sine * mode.displacement.y + const displacementY = sine * mode.displacement.x + cosine * mode.displacement.y + const displacement = vec2(displacementX, displacementY) + const waveVector = vec2(mode.waveVector.x, mode.waveVector.y) + const phase = dot(basePosition, waveVector) + .sub(timeNode.mul(mode.speed)) + .add(mode.phase + variant.phaseBias * (0.73 + index * 0.58)) + const phaseCosine = cos(phase) + warpedPosition.addAssign(displacement.mul(sin(phase))) + derivativeX.addAssign(displacement.mul(phaseCosine).mul(waveVector.x)) + derivativeZ.addAssign(displacement.mul(phaseCosine).mul(waveVector.y)) + } + + const energy = float(0.82).toVar() + const energyGradient = vec2(0).toVar() + for (let index = 0; index < OCEAN_ENERGY_WAVES.length; index += 1) { + const mode = OCEAN_ENERGY_WAVES[index] + const waveVector = vec2(mode.waveVector.x, mode.waveVector.y) + const phase = dot(basePosition, waveVector) + .sub(timeNode.mul(mode.speed)) + .add(mode.phase + variant.phaseBias * (1.11 + index * 0.47)) + energy.addAssign(sin(phase).mul(mode.amplitude)) + energyGradient.addAssign(waveVector.mul(cos(phase)).mul(mode.amplitude)) + } + + return { + warpedPosition, + derivativeX, + derivativeZ, + energy, + energyGradient + } +} + +export function createWavePositionNode( + timeNode: Node<'float'>, + verticalOffset = 0, + varyings: WaveVaryings = {} +): Node<'vec3'> { + return Fn(() => { + const displaced = positionLocal.toVar() + const basePosition = positionLocal.xz + const domains = OCEAN_DOMAIN_VARIANTS.map((variant) => + sampleOceanDomain(basePosition, timeNode, variant) + ) + + const cameraDistance = cameraPosition.sub(positionLocal).length() + const gradient = vec2(0).toVar() + for (let index = 0; index < OCEAN_WAVES.length; index += 1) { + const wave = OCEAN_WAVES[index] + const domain = domains[index % domains.length] + const direction = vec2(wave.direction.x, wave.direction.y) + const perpendicular = vec2(-wave.direction.y, wave.direction.x) + const along = dot(direction, domain.warpedPosition) + const across = dot(perpendicular, domain.warpedPosition) + const alongGradient = vec2( + dot(direction, domain.derivativeX), + dot(direction, domain.derivativeZ) + ) + const acrossGradient = vec2( + dot(perpendicular, domain.derivativeX), + dot(perpendicular, domain.derivativeZ) + ) + const bendPhase = across + .mul(wave.bendFrequency) + .add(wave.phase * 1.71) + .sub(timeNode.mul(0.055)) + const secondaryBendPhase = across + .mul(wave.bendFrequency * 2.13) + .sub(wave.phase * 0.73) + .add(timeNode.mul(0.035)) + const bend = sin(bendPhase).add(sin(secondaryBendPhase).mul(0.27)).mul(wave.bendStrength) + const packetPhase = along + .mul(0.34) + .add(across) + .mul(wave.packetFrequency) + .add(wave.phase * 2.07) + const secondaryPacketPhase = along + .mul(-0.18) + .add(across.mul(1.83)) + .mul(wave.packetFrequency) + .sub(wave.phase * 1.31) + const packetEnvelope = float(1).add( + sin(packetPhase).mul(0.68).add(sin(secondaryPacketPhase).mul(0.32)).mul(wave.packetStrength) + ) + const envelope = packetEnvelope.mul(domain.energy) + const waveNumber = (Math.PI * 2) / wave.wavelength + const lod = float(1).sub(smoothstep(wave.lodStart, wave.lodEnd, cameraDistance)) + const amplitude = lod.mul(wave.steepness / waveNumber) + const phase = along.add(bend).sub(timeNode.mul(wave.speed)).mul(waveNumber).add(wave.phase) + const height = sin(phase).sub(cos(phase.mul(2)).mul(wave.crestSharpness)) + const shapedDerivative = cos(phase).add(sin(phase.mul(2)).mul(wave.crestSharpness * 2)) + const bendDerivative = cos(bendPhase) + .mul(wave.bendFrequency) + .add(cos(secondaryBendPhase).mul(wave.bendFrequency * 2.13 * 0.27)) + .mul(wave.bendStrength) + const phaseGradient = alongGradient.add(acrossGradient.mul(bendDerivative)).mul(waveNumber) + const packetGradient = alongGradient + .mul(0.34) + .add(acrossGradient) + .mul(cos(packetPhase).mul(0.68 * wave.packetFrequency)) + .add( + alongGradient + .mul(-0.18) + .add(acrossGradient.mul(1.83)) + .mul(cos(secondaryPacketPhase).mul(0.32 * wave.packetFrequency)) + ) + .mul(wave.packetStrength) + const envelopeGradient = packetGradient + .mul(domain.energy) + .add(domain.energyGradient.mul(packetEnvelope)) + + displaced.xz.addAssign(direction.mul(amplitude).mul(envelope).mul(cos(phase))) + displaced.y.addAssign(amplitude.mul(envelope).mul(height)) + gradient.addAssign( + envelopeGradient + .mul(height) + .add(phaseGradient.mul(envelope).mul(shapedDerivative)) + .mul(amplitude) + ) + } + + varyings.normal?.assign(normalize(vec3(gradient.x.negate(), 1, gradient.y.negate()))) + const averagedSurfacePosition = domains + .slice(1) + .reduce((sum, domain) => sum.add(domain.warpedPosition), domains[0].warpedPosition) + .div(domains.length) + varyings.surfacePosition?.assign(averagedSurfacePosition) + varyings.waveHeight?.assign(displaced.y) + varyings.waveSlope?.assign(gradient.length()) + displaced.y.addAssign(verticalOffset) + return displaced + })() +} From 1172d3068edbfc08afde8078fe83e0a24f94cea2 Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Wed, 21 Oct 2026 15:59:54 +0800 Subject: [PATCH 11/13] chore: release version 0.7.0 --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c0c2a0..1f27d56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [0.7.0] - 2026-10-21 + +### Added + +- Add the `Water` component: a displaced plane driven by a 14 wave directional spectrum that is shared with a CPU sampler, so the waterline test follows the surface the shader renders. Shading covers Fresnel reflectance, GGX sun glitter, foam ribbons, depth based water colors, planar reflection and refraction, and an eased transition when the camera crosses the surface. +- Capture planar reflections and refractions explicitly on the WebGL backend, while WebGPU resolves both through the `reflector()` and `viewportSharedTexture()` nodes. Captures are sized from the drawing buffer and skipped while the camera is underwater. +- Add the `HeightFog` component: a screen-space exponential fog layer whose density falls off with world height, with flowing noise and directional inscattering towards a configurable light azimuth and elevation. +- Add the `Fireworks` class: one shader-driven shell that launches a streak and bursts into gravity-bent ribbon trails, with options for burst height, scale, color, timing and trail geometry resolution. +- Add WebGPU / WebGL demos and bilingual (English and Chinese) documentation for the three new effects, and extend the public API surface guard test with the new exports. +- Add unit tests for the wave spectrum sampler, the procedural noise map, the water material graph and the WebGL mirror camera projection. + ## [0.6.0] - 2026-10-08 ### Added diff --git a/package.json b/package.json index 0377274..7e25f6b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-three-lite", "type": "module", - "version": "0.6.0", + "version": "0.7.0", "author": "Shing Rui ", "repository": { "type": "git", From 08bc08ff9598993936f7e49e7d827e7c4ada535b Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Wed, 21 Oct 2026 16:02:45 +0800 Subject: [PATCH 12/13] docs: list new effects and note PR workflow - Add HeightFog, Fireworks and Water to the English and Chinese README feature lists - Note that the gh CLI is unavailable so release pull requests are opened through the GitHub compare link --- AGENTS.md | 5 ++++- README.md | 3 +++ README_ZH.md | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ccc6e33..1be5f70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -257,7 +257,10 @@ exactly - the tag is the last step because pushing it triggers the is fine. 3. Commit the release preparation on `develop` (`chore: release version x.y.z`). -4. Push `develop` and open a pull request from `develop` to `main`. +4. Push `develop` and open a pull request from `develop` to `main`. The `gh` + CLI is not installed in this environment, so never attempt `gh pr create`; + open the pull request in the browser instead: + `https://github.com/sunquakes/react-three-lite/compare/main...develop?expand=1`. 5. After the PR is reviewed and merged into `main`, create the tag on the `main` merge commit (`git checkout main && git pull && git tag x.y.z`) and push only then (`git push origin x.y.z`). diff --git a/README.md b/README.md index 9b02769..8fa014e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ Visit [r3l.sunquakes.com](https://r3l.sunquakes.com). - **Scene** — Core 3D scene container with camera, renderer, lights, and controls - **Model Loaders** — GLTF / FBX / OBJ loaders with Draco compression support - **Bloom** — Post-processing glow effect +- **HeightFog** — Screen-space exponential height fog +- **Fireworks** — Shader-driven firework launch with gravity-bent burst trails - **Rain / Snow** — Particle effects with custom GLSL shaders +- **Water** — Cinematic shallow ocean with planar reflection and refraction - **SweepLight** — Model sweep light animation - **LightGradient** — Dynamic lighting gradient effect - **Callout** — Leader line annotation with auto-anchor camera tracking diff --git a/README_ZH.md b/README_ZH.md index 0500d17..88e7050 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -22,7 +22,10 @@ - **Scene** — 核心 3D 场景容器,集成相机、渲染器、灯光和控制器 - **Model Loaders** — GLTF / FBX / OBJ 模型加载器,支持 Draco 压缩 - **Bloom** — 后处理辉光效果 +- **HeightFog** — 屏幕空间指数高度雾 +- **Fireworks** — 基于着色器的烟花升空与重力弯曲拖尾 - **Rain / Snow** — 基于 GLSL 着色器的粒子效果 +- **Water** — 电影级浅海效果,支持平面反射与折射 - **SweepLight** — 模型扫描光线动画 - **LightGradient** — 动态光照渐变效果 - **Callout** — 引线注释,支持自动锚点跟随相机 From 45b3f59d118d8247a73234ca8b21b0160219e01d Mon Sep 17 00:00:00 2001 From: Shing Rui Date: Wed, 21 Oct 2026 16:11:40 +0800 Subject: [PATCH 13/13] chore: normalize line endings with gitattributes - Store LF in the repository and check out LF so prettier output no longer produces whole-file line ending diffs - Mark binary assets so they are never converted --- .gitattributes | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1d6b8ba --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Normalize line endings. +# +# Prettier writes LF, so LF is canonical in the repository and in the working +# tree. Without this, running `pnpm format` on Windows turns every touched file +# into a whole-file diff that is pure line-ending noise. +* text=auto eol=lf + +# Binary assets are never converted. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.ttf binary +*.woff binary +*.woff2 binary +*.glb binary +*.fbx binary +*.zip binary