diff --git a/app/components/Form.tsx b/app/components/Form.tsx index ba29323..10d871a 100644 --- a/app/components/Form.tsx +++ b/app/components/Form.tsx @@ -14,10 +14,9 @@ import { SceneViewContext, SceneViewContextType, } from "../providers/scene-view-provider"; -import { Mesh } from "three"; +import { Mesh, MeshStandardMaterial } from "three"; import { CustomEvent } from "../three/SceneView"; import { RangeSlider } from "./RangeSlider"; -import { MeshStandardNodeMaterial } from "three/webgpu"; function Toggle({ title, @@ -264,10 +263,10 @@ export function Form() { const key = `toggle-visibility-${child.name}`; let color = "transparent"; if ( - (child as Mesh).material instanceof MeshStandardNodeMaterial + (child as Mesh).material instanceof MeshStandardMaterial ) { color = `#${( - (child as Mesh).material as MeshStandardNodeMaterial + (child as Mesh).material as MeshStandardMaterial ).color.getHexString()}`; } const visible = (child as Mesh).visible; diff --git a/app/three/SceneView.ts b/app/three/SceneView.ts index 131e16b..9f8ab8f 100644 --- a/app/three/SceneView.ts +++ b/app/three/SceneView.ts @@ -1,8 +1,10 @@ import { Group, + Material, Mesh, MeshBasicMaterial, MeshPhongMaterial, + MeshStandardMaterial, PerspectiveCamera, Plane, Raycaster, @@ -10,6 +12,7 @@ import { SphereGeometry, Vector2, Vector3, + WebGLRenderer, } from "three"; import { buildMeshes } from "./utils/build-meshes"; import { Extent, animate, buildScene } from "./utils/build-scene"; @@ -36,11 +39,6 @@ import { } from "geo-three"; import { Data, createSVG } from "./utils/create-borehole-svg"; import { TileData, updateTiles } from "./ShaderMaterial"; -import { - ClippingGroup, - MeshStandardNodeMaterial, - WebGPURenderer, -} from "three/webgpu"; export type CustomEvent = CustomEventInit<{ element: SVGSVGElement | null; @@ -48,7 +46,7 @@ export type CustomEvent = CustomEventInit<{ export class SceneView extends EventTarget { private _scene: Scene; - private _model: ClippingGroup; + private _model: Group; private _camera: PerspectiveCamera; private _container: HTMLElement; private _raycaster: Raycaster; @@ -60,17 +58,17 @@ export class SceneView extends EventTarget { private _callback: EventListenerOrEventListenerObject | null = null; private _orbitControls: OrbitControls; private _dragControls: DragControls | null = null; - private _renderer: WebGPURenderer; + private _renderer: WebGLRenderer; private static _DISPLACEMENT = 2000; constructor( scene: Scene, - model: ClippingGroup, + model: Group, camera: PerspectiveCamera, container: HTMLElement, extent: Extent, orbitControls: OrbitControls, - renderer: WebGPURenderer + renderer: WebGLRenderer ) { super(); this._scene = scene; @@ -153,7 +151,7 @@ export class SceneView extends EventTarget { // Set wireframe for model const model = this._model; model.children.forEach((child) => { - const material = (child as Mesh).material as MeshStandardNodeMaterial; + const material = (child as Mesh).material as MeshStandardMaterial; material.wireframe = !material.wireframe; }); @@ -164,7 +162,7 @@ export class SceneView extends EventTarget { if (capMeshGroup) { capMeshGroup.children.forEach((mesh) => { - const material = (mesh as Mesh).material as MeshStandardNodeMaterial; + const material = (mesh as Mesh).material as MeshStandardMaterial; if (material) { material.wireframe = !material.wireframe; } @@ -215,7 +213,7 @@ export class SceneView extends EventTarget { const depthEnd = intersects[i + 1].point.z; const name = intersects[i].object.name; const color = `#${( - (intersects[i].object as Mesh).material as MeshStandardNodeMaterial + (intersects[i].object as Mesh).material as MeshStandardMaterial ).color.getHexString()}`; // Avoid duplicate entries, just update the depth information @@ -341,9 +339,10 @@ export class SceneView extends EventTarget { // Remove existing cap meshes for (const o in Orientation) { const capMeshGroupName = `cap-mesh-group-${o}`; - const capMeshGroup = this._scene.getObjectByName(capMeshGroupName); - if (capMeshGroup) { - capMeshGroup.clear(); + let capMeshGroup = this._scene.getObjectByName(capMeshGroupName); + while (capMeshGroup) { + this._scene.remove(capMeshGroup); + capMeshGroup = this._scene.getObjectByName(capMeshGroupName); } } @@ -353,8 +352,10 @@ export class SceneView extends EventTarget { this._dragControls = null; } - // Disable clipping group - this.model.enabled = false; + // Remove clipping planes + for (const mesh of this._model.children) { + ((mesh as Mesh).material as Material).clippingPlanes = null; + } } // Reset clipping box @@ -372,9 +373,10 @@ export class SceneView extends EventTarget { this._dragControls = dragControls; - // Add planes to ClippingGroup - this.model.clippingPlanes = planes; - this.model.enabled = true; + // Add clipping planes to the meshes + for (const mesh of this._model.children) { + ((mesh as Mesh).material as Material).clippingPlanes = planes; + } } // Explode meshes @@ -398,7 +400,7 @@ export class SceneView extends EventTarget { this._resetClippingBox(); } - for (let i = 1; i < this._model.children.length; i++) { + for (let i = 0; i < this._model.children.length; i++) { const mesh = this._model.children[i]; if (explode) { @@ -450,13 +452,14 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { const { renderer, scene, camera, controls } = buildScene(container, extent); + // Start render loop + renderer.setAnimationLoop(animate(() => {})); + // Build the 3D model - const meshes = await buildMeshes(mappedFeatures); - const model = new ClippingGroup(); - model.enabled = false; - model.add(...meshes); + const model = new Group(); model.name = "geologic-model"; scene.add(model); + await buildMeshes(mappedFeatures, model); // Add a coordinate grid to the scene const { gridHelper, annotations } = buildCoordinateGrid(extent); @@ -488,14 +491,11 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { map.visible = false; scene.add(map); + // Update render loop to include topography const topography = scene.getObjectByName("Topography") as Mesh; - if (topography) { - renderer.setAnimationLoop( - animate( - rendererCallback(camera, renderer, scene, map, extent, topography) - ) - ); - } + renderer.setAnimationLoop( + animate(rendererCallback(camera, renderer, scene, map, extent, topography)) + ); return { scene, @@ -509,15 +509,14 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { function rendererCallback( camera: PerspectiveCamera, - renderer: WebGPURenderer, + renderer: WebGLRenderer, scene: Scene, map: MapView, extent: Extent, - topography: Mesh + topography: Mesh | undefined ) { return () => { - if (topography.visible) { - //@ts-expect-error WebGPURenderer is not supported by Geo-Three + if (topography && topography.visible) { map.lod.updateLOD(map, camera, renderer, scene); const tiles: TileData[] = []; traverse(map.root, extent, tiles); diff --git a/app/three/ShaderMaterial.ts b/app/three/ShaderMaterial.ts index fb50352..11f61ca 100644 --- a/app/three/ShaderMaterial.ts +++ b/app/three/ShaderMaterial.ts @@ -1,24 +1,13 @@ import { + Color, DataArrayTexture, LinearFilter, RGBAFormat, - SRGBColorSpace, + ShaderChunk, + ShaderMaterial, Texture, Vector4, } from "three"; -import { - Break, - Fn, - If, - Loop, - oneMinus, - positionWorld, - texture, - uniform, - uniformArray, - vec4, -} from "three/tsl"; -import { MeshStandardNodeMaterial } from "three/webgpu"; export interface TileData { xmin: number; @@ -37,9 +26,9 @@ const height = 256; const size = width * height; const canvas = new OffscreenCanvas(width, height); -const ctx = canvas.getContext("2d", { willReadFrequently: true }); +const ctx = canvas.getContext("2d"); -const tileBounds: Vector4[] = Array(maxTiles).fill(new Vector4(0, 0, 0, 0)); +const tileBounds = Array(maxTiles).fill(new Vector4(0, 0, 0, 0)); const data = new Uint8Array(4 * size * maxTiles); const tileCache: { @@ -53,65 +42,92 @@ dataArrayTexture.format = RGBAFormat; dataArrayTexture.generateMipmaps = false; dataArrayTexture.magFilter = LinearFilter; dataArrayTexture.minFilter = LinearFilter; -dataArrayTexture.colorSpace = SRGBColorSpace; dataArrayTexture.needsUpdate = true; // Create shader material -export const topoNodeMaterial = new MeshStandardNodeMaterial({ - alphaToCoverage: true, -}); -const tileBoundsUniform = uniformArray(tileBounds); -const dataArrayTextureUniform = uniform(dataArrayTexture); +export const shaderMaterial = new ShaderMaterial({ + clipping: true, + uniforms: { + tileBounds: { value: tileBounds }, + tileCount: { value: maxTiles }, + tiles: { value: dataArrayTexture }, + color: { value: new Color(1, 1, 1) }, + }, + vertexShader: + ShaderChunk.common + + "\n" + + ShaderChunk.logdepthbuf_pars_vertex + + ` + varying vec3 vWorldPosition; + varying float fragDepth; -const fragmentShader = /*#__PURE__*/ Fn(() => { - const color = vec4(191.0 / 255.0, 209.0 / 255.0, 229.0 / 255.0, 1.0).toVar(); - Loop({ start: 0, end: maxTiles, condition: "<" }, ({ i }) => { - const bounds = tileBoundsUniform.element(i); + #include - If( - positionWorld.x - .greaterThanEqual(bounds.x) - .and(positionWorld.x.lessThanEqual(bounds.y)) - .and(positionWorld.y.greaterThanEqual(bounds.z)) - .and(positionWorld.y.lessThanEqual(bounds.w)), - () => { - const uv = positionWorld.xy - .sub(bounds.xz) - .div(bounds.yw.sub(bounds.xz)) - .toVar(); - uv.y.assign(oneMinus(uv.y)); + void main() { + #include + vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + fragDepth = (gl_Position.z / gl_Position.w + 1.0) * 0.5; - const tile = texture(dataArrayTextureUniform.value, uv); - color.assign(tile.depth(i)); - Break(); - } - ); - }); + #include + #include + + ` + + ShaderChunk.logdepthbuf_vertex + + ` + } +`, + fragmentShader: + ShaderChunk.logdepthbuf_pars_fragment + + ` + uniform vec4 tileBounds[${maxTiles}]; + uniform int tileCount; + uniform sampler2DArray tiles; + varying vec3 vWorldPosition; + varying float fragDepth; - return color; + #include + + void main() { + #include + + vec4 color = vec4(191.0/255.0, 209.0/255.0, 229.0/255.0, 1.0); // Default color + + for (int i = 0; i < ${maxTiles}; i++) { + if (i >= tileCount) break; // Only process available tiles + + vec4 bounds = tileBounds[i]; + + if (vWorldPosition.x >= bounds.x && vWorldPosition.x <= bounds.y && + vWorldPosition.y >= bounds.z && vWorldPosition.y <= bounds.w) { + + vec2 uv = (vWorldPosition.xy - bounds.xz) / (bounds.yw - bounds.xz); + uv = vec2(uv.x, 1.0 - uv.y); + color = texture2D(tiles, vec3(uv, i)); + + break; // Stop checking once we find the correct tile + } + } + + gl_FragColor = color; + gl_FragDepth = fragDepth; + ` + + ShaderChunk.logdepthbuf_fragment + + ` + } +`, }); -topoNodeMaterial.colorNode = fragmentShader(); - -let oldKeys: string[] = []; export function updateTiles(newTiles: TileData[]) { if (newTiles.length > maxTiles) { newTiles = newTiles.slice(0, maxTiles); } - const newKeys = newTiles.map((t) => getTileDataKey(t)); - const update = - oldKeys.some((k, i) => k !== newKeys[i]) || oldKeys.length === 0; - - // Only update if tiles changed - if (update) { - for (let i = 0; i < newTiles.length; i++) { - updateDataArrayTexture(newTiles[i], i); - } - - dataArrayTexture.needsUpdate = true; - oldKeys = newKeys; + for (let i = 0; i < newTiles.length; i++) { + updateDataArrayTexture(newTiles[i], i); } + + dataArrayTexture.needsUpdate = true; } // Update buffer diff --git a/app/three/utils/build-clipping-planes.ts b/app/three/utils/build-clipping-planes.ts index ec5a083..4c93d9f 100644 --- a/app/three/utils/build-clipping-planes.ts +++ b/app/three/utils/build-clipping-planes.ts @@ -8,6 +8,8 @@ import { LineBasicMaterial, LineSegments, Mesh, + MeshBasicMaterial, + MeshStandardMaterial, Object3DEventMap, PerspectiveCamera, Plane, @@ -15,17 +17,11 @@ import { Scene, Vector2, Vector3, + WebGLRenderer, } from "three"; import { DragControls, OrbitControls } from "three/examples/jsm/Addons.js"; import { Extent } from "./build-scene"; import earcut from "earcut"; -import { - ClippingGroup, - MeshBasicNodeMaterial, - MeshStandardNodeMaterial, - WebGPURenderer, -} from "three/webgpu"; -import { color } from "three/tsl"; export enum Orientation { X = "X", @@ -36,7 +32,7 @@ export enum Orientation { NZ = "NZ", } -type PlaneMesh = Mesh; +type PlaneMesh = Mesh; type EdgeMesh = LineSegments< EdgesGeometry, LineBasicMaterial, @@ -53,7 +49,7 @@ let currentExtent: Extent; const BUFFER = 500; export function buildClippingplanes( - renderer: WebGPURenderer, + renderer: WebGLRenderer, camera: PerspectiveCamera, orbitControls: OrbitControls, extent: Extent, @@ -152,7 +148,7 @@ export function buildClippingplanes( const planeGeometry = new PlaneGeometry(width, height); const planeMesh = new Mesh( planeGeometry, - new MeshBasicNodeMaterial({ + new MeshBasicMaterial({ visible: true, color: 0xa92a4e, transparent: true, @@ -194,25 +190,6 @@ export function buildClippingplanes( edgeMeshMap[p.orientation] = edges; } - for (const p of planesData) { - // Create ClippingGroup for each cap mesh face - const capMeshGroupName = `cap-mesh-group-${p.orientation}`; - let capMeshGroup = scene.getObjectByName(capMeshGroupName) as ClippingGroup; - if (capMeshGroup) { - capMeshGroup.clear(); - } else { - capMeshGroup = new ClippingGroup(); - capMeshGroup.name = capMeshGroupName; - scene.add(capMeshGroup); - } - - // Set clipping planes for the cap meshes - const capMeshGroupPlanes = planes.filter( - (plane) => plane.normal.dot(p.normal) !== 1 - ); - capMeshGroup.clippingPlanes = capMeshGroupPlanes; - } - // Add meshes to the scene const planeMeshGroup = new Group(); planeMeshGroup.name = "clipping-planes"; @@ -411,21 +388,29 @@ export function buildClippingplanes( } // Remove existing cap meshes - const capMeshGroupName = `cap-mesh-group-${orientation}`; - const capMeshGroup = scene.getObjectByName( - capMeshGroupName - ) as ClippingGroup; - if (capMeshGroup) { - capMeshGroup.clear(); + const capMeshGroupName = `cap-mesh-group-${object.name}`; + let capMeshGroup = scene.getObjectByName(capMeshGroupName); + while (capMeshGroup) { + scene.remove(capMeshGroup); + capMeshGroup = scene.getObjectByName(capMeshGroupName); + } - // Generate new cap meshes - generateCapMeshes( - meshes, - plane.clone(), - orientation, - scene, - capMeshGroup - ); + // Generate new cap meshes + const capMeshes = generateCapMeshes( + meshes, + plane.clone(), + planes, + orientation, + scene + ); + + // Add new cap meshes + if (capMeshes.length > 0) { + const newCapMeshGroup = new Group(); + + newCapMeshGroup.add(...capMeshes); + newCapMeshGroup.name = capMeshGroupName; + scene.add(newCapMeshGroup); } }); @@ -591,10 +576,12 @@ function resizeClippingPlane( function generateCapMeshes( meshes: Mesh[], plane: Plane, + planes: Plane[], orientation: Orientation, - scene: Scene, - capMeshGroup: ClippingGroup + scene: Scene ) { + const capMeshes: Mesh[] = []; + // Rescale to local coordinates if (orientation === Orientation.Z || orientation === Orientation.NZ) plane.constant /= scene.scale.z; @@ -653,32 +640,60 @@ function generateCapMeshes( // Intersection surface can be a multipolygon consisting of disconnected polygons const polygons: Vector3[][] = buildPolygons(edges); - const colorThree = - mesh.material instanceof MeshStandardNodeMaterial - ? mesh.material.color - : new Color(0.1, 0.1, 0.1); + // Clip cap surfaces with clipping planes + const clippingPlanes = planes.filter((p) => !p.normal.equals(plane.normal)); - const material = new MeshStandardNodeMaterial({ + const offset = + orientation === Orientation.NX || + orientation === Orientation.NY || + orientation === Orientation.NZ + ? 1 + : -1; + + const color = + mesh.material instanceof MeshStandardMaterial + ? mesh.material.color + : new Color(1, 1, 1); + + const material = new MeshStandardMaterial({ + color, side: DoubleSide, + metalness: 0.0, + roughness: 1.0, flatShading: true, + polygonOffset: true, + polygonOffsetFactor: offset, + polygonOffsetUnits: offset, + clippingPlanes, wireframe: scene.userData.wireframe, - alphaToCoverage: true, }); - material.colorNode = color(colorThree.r, colorThree.g, colorThree.b); - - polygons.forEach((polygon) => { + const localMeshes = polygons.map((polygon) => { const geometry = triangulatePolygon(polygon, plane); const capMesh = new Mesh(geometry, material); capMesh.visible = mesh.visible; capMesh.name = mesh.name; - if (capMesh && geometry.index && geometry.index.count > 0) { - capMeshGroup.add(capMesh); + // Offset mesh to avoid flickering + const normal = plane.normal.clone().multiplyScalar(offset); + + const positionAttr = capMesh.geometry.attributes.position; + for (let i = 0; i < positionAttr.count; i++) { + const x = positionAttr.getX(i) + normal.x; + const y = positionAttr.getY(i) + normal.y; + const z = positionAttr.getZ(i) + normal.z; + positionAttr.setXYZ(i, x, y, z); } + positionAttr.needsUpdate = true; + + return capMesh; }); + + capMeshes.push(...localMeshes); } + + return capMeshes; } // Build polygons by grouping connected intersection edges diff --git a/app/three/utils/build-meshes.ts b/app/three/utils/build-meshes.ts index 04d04c0..a5bbe37 100644 --- a/app/three/utils/build-meshes.ts +++ b/app/three/utils/build-meshes.ts @@ -1,10 +1,15 @@ -import { BufferAttribute, BufferGeometry, DoubleSide, Mesh } from "three"; +import { + BufferAttribute, + BufferGeometry, + DoubleSide, + Group, + Mesh, + MeshStandardMaterial, +} from "three"; import { fetchVertices, fetchTriangleIndices, transform } from "./utils"; import { TRIANGLE_INDICES_URL, VERTICES_URL } from "../config"; -import { topoNodeMaterial } from "../ShaderMaterial"; -import { MeshStandardNodeMaterial } from "three/webgpu"; -import { color } from "three/tsl"; +import { shaderMaterial } from "../ShaderMaterial"; interface MappedFeature { featuregeom_id: number; @@ -13,24 +18,22 @@ interface MappedFeature { preview: { legend_color: string; legend_text: string }; } -export async function buildMeshes(mappedFeatures: MappedFeature[]) { - const meshes = []; - for (let i = 0; i < mappedFeatures.length; i++) { - const layerData = mappedFeatures[i]; - const mesh = await buildMesh(layerData); - if (layerData.name === "Topography") { +export async function buildMeshes( + mappedFeatures: MappedFeature[], + model: Group +) { + for (const mappedFeature of mappedFeatures) { + const mesh = await buildMesh(mappedFeature); + if (mappedFeature.name === "Topography") { mesh.visible = false; - } else { - mesh.visible = true; } - meshes.push(mesh); - } - return meshes; + model.add(mesh); + } } async function buildMesh(layerData: MappedFeature) { - const colorHex = `#${layerData.preview.legend_color}`; + const color = `#${layerData.preview.legend_color}`; const name = layerData.preview.legend_text; const geomId = layerData.featuregeom_id.toString(); @@ -56,23 +59,19 @@ async function buildMesh(layerData: MappedFeature) { const indices = new BufferAttribute(indexArray, 1); geometry.setIndex(indices); - geometry.computeVertexNormals(); - const material = new MeshStandardNodeMaterial({ - color: colorHex, + const material = new MeshStandardMaterial({ + color: color, metalness: 0.1, roughness: 0.5, flatShading: true, side: DoubleSide, - alphaToCoverage: true, + wireframe: false, }); - // Required by ClippingGroup otherwise clipping does not work - material.colorNode = color(colorHex); - const mesh = new Mesh( geometry, - name === "Topography" ? topoNodeMaterial : material + name === "Topography" ? shaderMaterial : material ); mesh.name = name; mesh.userData.layerId = geomId; diff --git a/app/three/utils/build-scene.ts b/app/three/utils/build-scene.ts index 60a37b7..063eee1 100644 --- a/app/three/utils/build-scene.ts +++ b/app/three/utils/build-scene.ts @@ -1,6 +1,7 @@ import { PerspectiveCamera, Scene, + WebGLRenderer, AmbientLight, DirectionalLight, Group, @@ -18,7 +19,6 @@ import { import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { getCenter3D, getMaxSize } from "./utils"; -import { WebGPURenderer } from "three/webgpu"; export interface Extent { xmin: number; @@ -30,7 +30,7 @@ export interface Extent { } let controls: OrbitControls; -let renderer: WebGPURenderer; +let renderer: WebGLRenderer; let camera: PerspectiveCamera; let scene: Scene; let overlayCamera: OrthographicCamera; @@ -54,15 +54,15 @@ export function buildScene(container: HTMLElement, extent: Extent) { camera.lookAt(center); // Initialize the renderer - renderer = new WebGPURenderer({ + renderer = new WebGLRenderer({ logarithmicDepthBuffer: true, - antialias: true, }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(width, height); renderer.localClippingEnabled = true; renderer.autoClear = false; + // renderer.setAnimationLoop(animate); // Handle window resize event to adapt the aspect ratio window.addEventListener("resize", () => onWindowResize(container)); @@ -117,7 +117,7 @@ function onWindowResize(container: HTMLElement) { camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); - // Required if controls.enableDamping or controls.autoRotate are set to true + // required if controls.enableDamping or controls.autoRotate are set to true controls.update(); } @@ -152,12 +152,7 @@ function renderOverlay() { ); // Render the overlay scene to the screen (position it in the bottom left) - renderer.setViewport( - 10, - renderer.domElement.height - UI_HEIGHT - 10, - UI_WIDTH, - UI_HEIGHT - ); + renderer.setViewport(10, 10, UI_WIDTH, UI_HEIGHT); renderer.render(overlayScene, overlayCamera); renderer.setViewport( 0, @@ -184,7 +179,7 @@ function buildDefaultLights(scene: Scene, extent: Extent) { lights.push(ambient); // Directional lights - const directionalLight = new DirectionalLight(0xffffff, 2); + const directionalLight = new DirectionalLight(0xffffff, 1.5); directionalLight.position.set( lightPosition.x, lightPosition.y,