Showing preview only (266K chars total). Download the full file or copy to clipboard to get everything.
Repository: gkjohnson/three-edge-projection
Branch: main
Commit: d74a7e7d4539
Files: 75
Total size: 246.7 KB
Directory structure:
gitextract__n5tqol9/
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ └── workflows/
│ ├── examples-build.yml
│ └── node.js.yml
├── .gitignore
├── API.md
├── CHANGELOG.md
├── LICENSE
├── README.md
├── eslint.config.js
├── example/
│ ├── bimProjection.html
│ ├── bimProjection.js
│ ├── edgeProjection.html
│ ├── edgeProjection.js
│ ├── edgeProjectionWebGPU.html
│ ├── edgeProjectionWebGPU.js
│ ├── floorProjection.html
│ ├── floorProjection.js
│ ├── perspectiveProjection.html
│ ├── perspectiveProjection.js
│ ├── planarIntersection.html
│ ├── planarIntersection.js
│ ├── silhouetteProjection.html
│ └── silhouetteProjection.js
├── package.json
├── src/
│ ├── EdgeGenerator.js
│ ├── MeshVisibilityCuller.js
│ ├── PlanarIntersectionGenerator.js
│ ├── ProjectionGenerator.js
│ ├── SilhouetteGenerator.js
│ ├── index.js
│ ├── utils/
│ │ ├── LineObjectsBVH.js
│ │ ├── ProjectionEdge.js
│ │ ├── bvhcastEdges.js
│ │ ├── compressPoints.js
│ │ ├── generateEdges.js
│ │ ├── generateIntersectionEdges.js
│ │ ├── geometryUtils.js
│ │ ├── getAllMeshes.js
│ │ ├── getProjectedLineOverlap.js
│ │ ├── getProjectedOverlaps.js
│ │ ├── getSizeSortedTriList.js
│ │ ├── nextFrame.js
│ │ ├── overlapUtils.js
│ │ ├── planeUtils.js
│ │ ├── triangleIsInsidePaths.js
│ │ ├── triangleLineUtils.js
│ │ └── trimToBeneathTriPlane.js
│ ├── webgpu/
│ │ ├── MeshVisibilityCuller.js
│ │ ├── ProjectionGenerator.js
│ │ ├── ProjectionGeneratorBVHComputeData.js
│ │ ├── index.js
│ │ ├── kernels/
│ │ │ ├── EdgeOverlapsKernel.js
│ │ │ └── ZeroOutBufferKernel.js
│ │ ├── lib/
│ │ │ ├── BVHComputeData.js
│ │ │ ├── nodes/
│ │ │ │ ├── NodeProxy.js
│ │ │ │ └── WGSLTagFnNode.js
│ │ │ └── wgsl/
│ │ │ ├── common.wgsl.js
│ │ │ └── structs.wgsl.js
│ │ ├── nodes/
│ │ │ ├── common.wgsl.js
│ │ │ ├── overlapFunctions.wgsl.js
│ │ │ ├── primitives.js
│ │ │ ├── structs.wgsl.js
│ │ │ └── utils.wgsl.js
│ │ └── utils/
│ │ └── ComputeKernel.js
│ └── worker/
│ ├── SilhouetteGeneratorWorker.js
│ └── silhouetteAsync.worker.js
├── test/
│ ├── Utils.getProjectedLineOverlap.test.js
│ ├── Utils.triangleLineUtils.test.js
│ └── Utils.trimToBeneathTriPlane.test.js
├── utils/
│ ├── CommandUtils.js
│ └── docs/
│ ├── RenderDocsUtils.js
│ └── build.js
├── vite.config.js
└── vitest.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = tab
insert_final_newline = true
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: gkjohnson
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
================================================
FILE: .github/workflows/examples-build.yml
================================================
name: Deploy Examples to GitHub Pages
on:
push:
branches: [ main ]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
- run: npm ci
- run: npm run build-examples
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./example/dist
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
================================================
FILE: .github/workflows/node.js.yml
================================================
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "*" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [24.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm test
================================================
FILE: .gitignore
================================================
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
/dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
================================================
FILE: API.md
================================================
<!-- This file is generated automatically. Do not edit it directly. -->
# three-edge-projection
## Constants
### OUTPUT_MESH
```js
OUTPUT_MESH: number
```
### OUTPUT_LINE_SEGMENTS
```js
OUTPUT_LINE_SEGMENTS: number
```
### OUTPUT_BOTH
```js
OUTPUT_BOTH: number
```
## EdgeSet
Set of projected edges produced by ProjectionGenerator.
### .getLineGeometry
```js
getLineGeometry( meshes = null: Array<Mesh> | null ): BufferGeometry
```
Returns a new BufferGeometry representing the edges.
Pass a list of meshes in to extract edges from a specific subset of meshes in the given
order. Returns all edges if null.
### .getRangeForMesh
```js
getRangeForMesh( mesh: Mesh ): Object | null
```
Returns the range of vertices associated with the given mesh in the geometry returned from
getLineGeometry. The `start` value is only relevant if lines are generated with the default
order and set of meshes.
Can be used to add extra vertex attributes in a geometry associated with a specific subrange
of the geometry.
## MeshVisibilityCuller
Utility for determining visible geometry from a top down orthographic perspective. This can
be run before performing projection generation to reduce the complexity of the operation at
the cost of potentially missing small details.
Constructor for the visibility culler that takes the renderer to use for culling.
### .pixelsPerMeter
```js
pixelsPerMeter: number
```
The size of a pixel on a single dimension. If this results in a texture larger than what
the graphics context can provide then the rendering is tiled.
### .constructor
```js
constructor(
renderer: WebGLRenderer,
{
pixelsPerMeter = 0.1: number,
}
)
```
### .cull
```js
async cull( object: Object3D | Array<Object3D> ): Promise<Array<Object3D>>
```
Returns the set of meshes that are visible within the given object.
## PlanarIntersectionGenerator
Utility for generating the line segments produced by a planar intersection with geometry.
### .plane
```js
plane: Plane
```
Plane that defaults to y up plane at the origin.
### .generate
```js
generate( geometry: MeshBVH | BufferGeometry ): BufferGeometry
```
Generates a geometry of the resulting line segments from the planar intersection.
## ProjectionGenerator
Utility for generating 2D projections of 3D geometry.
### .iterationTime
```js
iterationTime: number
```
How long to spend trimming edges before yielding.
### .angleThreshold
```js
angleThreshold: number
```
The threshold angle in degrees at which edges are generated.
### .includeIntersectionEdges
```js
includeIntersectionEdges: boolean
```
Whether to generate edges representing the intersections between triangles.
### .generateAsync
```js
async generateAsync(
geometry: Object3D | BufferGeometry | Array<Object3D>,
{
onProgress?: (
percent: number,
message: string
) => void,
signal?: AbortSignal,
}
): ProjectionResult
```
Generate the geometry with a promise-style API.
### .generate
```js
generate(
scene: Object3D | BufferGeometry | Array<Object3D>,
{
onProgress?: (
percent: number,
message: string
) => void,
}
): ProjectionResult
```
Generate the edge geometry result using a generator function.
## ProjectionResult
Result object returned by ProjectionGenerator containing visible and hidden edge sets.
### .visibleEdges
```js
visibleEdges: EdgeSet
```
### .hiddenEdges
```js
hiddenEdges: EdgeSet
```
## SilhouetteGenerator
Used for generating a projected silhouette of a geometry using the clipper2-js project. Performing
these operations can be extremely slow with more complex geometry and not always yield a stable result.
### .iterationTime
```js
iterationTime: number
```
How long to spend trimming edges before yielding.
### .doubleSided
```js
doubleSided: boolean
```
If `false` then only the triangles facing upwards are included in the silhouette.
### .sortTriangles
```js
sortTriangles: boolean
```
Whether to sort triangles and project them large-to-small. In some cases this can cause
the performance to drop since the union operation is best performed with smooth, simple
edge shapes.
### .output
```js
output: number
```
Whether to output mesh geometry, line segments geometry, or both in an array
( `[ mesh, line segments ]` ).
### .generateAsync
```js
async generateAsync(
geometry: BufferGeometry,
{
onProgress?: (
percent: number
) => void,
signal?: AbortSignal,
}
): BufferGeometry | Array<BufferGeometry>
```
Generate the silhouette geometry with a promise-style API.
### .generate
```js
generate(
geometry: BufferGeometry,
{
onProgress?: (
percent: number
) => void,
}
): BufferGeometry | Array<BufferGeometry>
```
Generate the geometry using a generator function.
# WebGPU API
## MeshVisibilityCuller
Utility for determining visible geometry from a top down orthographic perspective. This can
be run before performing projection generation to reduce the complexity of the operation at
the cost of potentially missing small details.
Takes the WebGPURenderer instance used to render.
### .pixelsPerMeter
```js
pixelsPerMeter: number
```
The size of a pixel on a single dimension. If this results in a texture larger than what
the graphics context can provide then the rendering is tiled.
### .constructor
```js
constructor(
renderer: WebGPURenderer,
{
pixelsPerMeter = 0.1: number,
}
)
```
### .cull
```js
async cull( object: Object3D | Array<Object3D> ): Promise<Array<Object3D>>
```
Returns the set of meshes that are visible within the given object.
## ProjectionGenerator
Takes the WebGPURenderer instance used to run compute kernels.
### .angleThreshold
```js
angleThreshold: number
```
The threshold angle in degrees at which edges are generated.
### .batchSize
```js
batchSize: number
```
The number of edges to process in one compute kernel pass. Larger values can process
faster but may cause internal buffers to overflow, resulting in extra kernel executions,
taking more time.
### .includeIntersectionEdges
```js
includeIntersectionEdges: boolean
```
Whether to generate edges representing the intersections between triangles.
### .iterationTime
```js
iterationTime: number
```
How long to spend generating edges.
### .parallelJobs
```js
parallelJobs: number
```
How many compute jobs to perform in parallel.
### .constructor
```js
constructor( renderer: WebGPURenderer )
```
### .generate
```js
async generate(
scene: Object3D | BufferGeometry | Array<Object3D>,
{
onProgress?: (
percent: number,
message: string
) => void,
signal?: AbortSignal,
}
): Promise<ProjectionResult>
```
Asynchronously generate the edge geometry result.
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [0.0.9] - 2026.04.18
### Added
- Use of "ReadbackBuffer" from three.js r184 to improve performance, memory management.
## [0.0.8] - 2026.04.03
### Added
- Support for projection matrix transformations.
### Changed
- Increased time spent per frame on edge generation.
## [0.0.7] - 2026.03.31
### Fixed
- ProjectionGenerator: Fixed intersection edges not being generated correctly.
## [0.0.6] - 2026.03.31
### Fixed
- MeshVisibilityCuller: fix case where the id buffer could be corrupted with separate renders.
- MeshVisibilityCuller: fix incorrect tiling resulting in incorrect results.
### Changed
- ProjectionGenerator: Adjust the "onProgress" option callback to always take the "progress" number as the first argument.
### Added
- Add a "three-edge-projection/webgpu" export including a WebGPURenderer-compatible MeshVsibilityCuller, ProjectionGenerator.
## [0.0.5] - 2025.01.29
### Fixed
- Accidental variable conflict.
- Add support for passing arrays of objects to MeshVisibilityCuller & ProjectionGenerator.
## [0.0.4] - 2025.01.29
### Changed
- ProjectionGenerator now returns an object with functions for extracting edges.
### Added
- Ability to extract hidden edges in addition to visible edges.
- Optimizations to increase generation speed.
- Remove requirement to merge geometry ahead of time.
- A "MeshVisibilityCuller" class that can be run to help reduce the number of meshes that need to be processed.
### Removed
- ProjectionGeneratorWorker
## [0.0.3] - 2025.04.04
### Added
- PlanarIntersectionGenerator for generating model cross sections.
## [0.0.2] - 2023.09.30
### Added
- SilhouetteGenerator: performance improvements by skipping unnecessary triangles that are determined to already be in the shape.
- SilhouetteGenerator: Perform simplification of edges.
- SilhouetteGenerator: Add ability to see outline and mesh edges.
- ProjectionGenerator: `includeIntersectionEdges` option defaults to true.
## [0.0.1] - 2023.09.18
### Fixed
- Some missing edges in projection
### Changed
- Largely simplified code
- Migrated logic from three-mesh-bvh
### Added
- ProjectionGenerator class for generating flattened, projected edges
- SilhouetteGenerator class for generating flattened, projected silhouette geometry (slow and sometimes unstable)
- Ability to generate intersection edges for projection with `ProjectionGenerator.includeIntersectionEdges`
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 Garrett Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# three-edge-projection
[](https://github.com/gkjohnson/three-edge-projection/actions)
[](https://github.com/gkjohnson/three-edge-projection/)
[](https://twitter.com/garrettkjohnson)
[](https://github.com/sponsors/gkjohnson/)

Edge projection based on [three-mesh-bvh](https://github.com/gkjohnson/three-mesh-bvh/) to extract visible projected lines along the y-axis into flattened line segments for scalable 2d rendering. Additonally includes a silhouette mesh generator based on [clipper2-js](https://www.npmjs.com/package/clipper2-js) to merge flattened triangles.
# Examples
[Rover edge projection](https://gkjohnson.github.io/three-edge-projection/edgeProjection.html)
[Lego edge projection](https://gkjohnson.github.io/three-edge-projection/edgeProjection.html#lego)
[Silhouette projection](https://gkjohnson.github.io/three-edge-projection/silhouetteProjection.html)
[Floor plan projection](https://gkjohnson.github.io/three-edge-projection/floorProjection.html)
[Planar intersection](https://gkjohnson.github.io/three-edge-projection/planarIntersection.html)
### WebGPU
[Rover edge projection](https://gkjohnson.github.io/three-edge-projection/edgeProjectionWebGPU.html)
# Installation
```
npm install github:@gkjohnson/three-edge-projection
```
# API
See [API.md](./API.md) for full API documentation.
# Use
**Generator**
More granular API with control over when edge trimming work happens.
```js
const generator = new ProjectionGenerator();
generator.generate( scene );
let result = task.next();
while ( ! result.done ) {
result = task.next();
}
const lines = new LineSegments( result.value.getVisibleLineGeometry(), material );
scene.add( lines );
```
**Promise**
Simpler API with less control over when the work happens.
```js
const generator = new ProjectionGenerator();
const result = await generator.generateAsync( scene );
const mesh = new Mesh( result.getVisibleLineGeometry(), material );
scene.add( mesh );
```
**Visibility Culling**
To visibility cull a scene before generation you can use MeshVisibilityCuller before running the projection step.
```js
const input = new MeshVisibilityCuller( renderer ).cull( scene );
const result = await generator.generateAsync( scene );
const mesh = new Mesh( result.getVisibleLineGeometry(), material );
scene.add( mesh );
```
================================================
FILE: eslint.config.js
================================================
import js from '@eslint/js';
import globals from 'globals';
import mdcs from 'eslint-config-mdcs';
import tseslint from 'typescript-eslint';
import vitest from '@vitest/eslint-plugin';
import jsdoc from 'eslint-plugin-jsdoc';
export default [
// files to ignore
{
name: 'files to ignore',
ignores: [
'**/node_modules/**',
'**/build/**',
'**/dist/**',
],
},
// recommended
js.configs.recommended,
...tseslint.configs.recommended.map( config => ( {
...config,
files: [ '**/*.ts' ],
} ) ),
// base rules
{
name: 'base rules',
files: [ '**/*.js' ],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
...globals.browser,
...globals.node,
},
},
rules: {
...mdcs.rules,
'no-unused-vars': [ 'warn', {
vars: 'all',
args: 'none',
} ],
},
},
// ts rule overrides
{
name: 'ts rule overrides',
files: [ '**/*.ts' ],
rules: {
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [ 'error', { args: 'none' } ],
indent: [ 'error', 2 ],
},
},
// jsdoc
{
name: 'jsdoc rules',
files: [ '**/*.js' ],
plugins: {
jsdoc,
},
settings: {
jsdoc: {
preferredTypes: {
Any: 'any',
Boolean: 'boolean',
Number: 'number',
object: 'Object',
String: 'string',
},
tagNamePreference: {
return: 'returns',
augments: 'extends',
classdesc: false,
},
},
},
rules: {
'jsdoc/check-tag-names': [ 'error', { definedTags: [ 'warn', 'note', 'section' ] } ],
'jsdoc/check-types': 'error',
'jsdoc/no-undefined-types': 'error',
'jsdoc/require-param-type': 'error',
'jsdoc/require-returns-type': 'error',
'jsdoc/require-returns': 'off',
'jsdoc/require-param-description': 'off',
'jsdoc/require-returns-description': 'off',
},
},
// vitest
{
name: 'vitest rules',
files: [ '**/*.test.js', '**/*.test.ts', '**/*.spec.js', '**/*.spec.ts' ],
plugins: {
vitest,
},
languageOptions: {
globals: {
...vitest.environments.env.globals,
},
},
rules: {
...vitest.configs.recommended.rules,
},
},
];
================================================
FILE: example/bimProjection.html
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
body {
margin: 0;
padding: 0;
font-family: "Plus Jakarta Sans", sans-serif;
overflow: hidden;
}
.full-screen {
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
}
#output {
position: fixed;
bottom: 0;
left: 0;
padding: 10px;
font-size: 12px;
color: #c9c9c9;
z-index: 1000;
}
</style>
</head>
<body>
<div id="output"></div>
<div id="container" class="full-screen"></div>
<script type="module" src="bimProjection.js"></script>
</body>
</html>
================================================
FILE: example/bimProjection.js
================================================
import {
Quaternion,
AxesHelper,
Group,
MeshLambertMaterial,
BufferGeometry,
Float32BufferAttribute,
Mesh,
Box3,
Vector3,
PlaneGeometry,
MeshBasicMaterial,
LineBasicMaterial,
LineSegments,
LineDashedMaterial,
Matrix4,
BoxGeometry,
} from 'three';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { MeshBVH, SAH } from 'three-mesh-bvh';
import * as OBC from '@thatopen/components';
import * as WEBIFC from 'web-ifc';
import { GeometryEngine } from '@thatopen/fragments';
import { PlanarIntersectionGenerator } from 'three-edge-projection';
import { WebGPURenderer } from 'three/webgpu';
import { ProjectionGenerator, MeshVisibilityCuller } from 'three-edge-projection/webgpu';
const params = {
displayModel: true,
displayDrawThroughProjection: false,
includeIntersectionEdges: false,
enableClipping: false,
displayClippingEdges: true,
rotate: () => {
const randomQuaternion = new Quaternion();
randomQuaternion.random();
allMeshes.quaternion.copy( randomQuaternion );
allMeshes.position.set( 0, 0, 0 );
allMeshes.updateMatrixWorld( true );
},
regenerate: () => {
updateEdges();
},
};
const ANGLE_THRESHOLD = 50;
let gui;
let projection, drawThroughProjection;
let outputContainer;
// Initialize a WebGPU renderer for compute (separate from OBC's WebGL display renderer)
const gpuRenderer = new WebGPURenderer();
await gpuRenderer.init();
const components = new OBC.Components();
components.init();
const container = document.getElementById( 'container' );
const world = components.get( OBC.Worlds ).create();
world.scene = new OBC.SimpleScene( components );
world.renderer = new OBC.SimpleRenderer( components, container );
world.camera = new OBC.OrthoPerspectiveCamera( components );
world.scene.setup();
world.scene.three.add( new AxesHelper() );
outputContainer = document.getElementById( 'output' );
// Initialize GeometryEngine for boolean operations
const ifcApi = new WEBIFC.IfcAPI();
ifcApi.SetWasmPath( 'https://unpkg.com/web-ifc@0.0.75/', false );
await ifcApi.Init();
const geometryEngine = new GeometryEngine( ifcApi );
// init fragments worker
const githubUrl = 'https://thatopen.github.io/engine_fragment/resources/worker.mjs';
const req = await fetch( githubUrl );
const blob = await req.blob();
const workerFile = new File( [ blob ], 'worker.mjs', { type: 'text/javascript' } );
const workerUrl = URL.createObjectURL( workerFile );
const fragments = components.get( OBC.FragmentsManager );
fragments.init( workerUrl );
world.camera.controls.addEventListener( 'control', () => {
fragments.core.update( true );
} );
// Remove z fighting
fragments.core.models.materials.list.onItemSet.add( ( { value: material } ) => {
if ( ! ( 'isLodMaterial' in material && material.isLodMaterial ) ) {
material.polygonOffset = true;
material.polygonOffsetUnits = 1;
material.polygonOffsetFactor = Math.random();
}
} );
const model = await loadModel( '/frags/m3d.frag' );
const allMeshes = new Group();
// world.scene.three.add(allMeshes);
// Separate group for clipped results
const clippedMeshes = new Group();
// world.scene.three.add(clippedMeshes);
const material = new MeshLambertMaterial();
// Add picking meshes (deduplicating geometries to save memory)
const idsWithGeometry = await model.getItemsIdsWithGeometry();
const allMeshesData = await model.getItemsGeometry( idsWithGeometry );
const geometries = new Map();
for ( const itemId in allMeshesData ) {
const meshData = allMeshesData[ itemId ];
for ( const geomData of meshData ) {
if (
! geomData.positions ||
! geomData.indices ||
! geomData.transform ||
! geomData.representationId
) {
continue;
}
const representationId = geomData.representationId;
if ( ! geometries.has( representationId ) ) {
const geometry = new BufferGeometry();
geometry.setAttribute( 'position', new Float32BufferAttribute( geomData.positions, 3 ) );
geometry.setAttribute( 'normal', new Float32BufferAttribute( geomData.normals, 3 ) );
geometry.setIndex( Array.from( geomData.indices ) );
geometries.set( representationId, geometry );
}
const geometry = geometries.get( representationId );
const mesh = new Mesh( geometry, material );
mesh.applyMatrix4( geomData.transform );
mesh.applyMatrix4( model.object.matrixWorld );
mesh.updateWorldMatrix( true, true );
allMeshes.add( mesh );
}
}
// initialize BVHs
allMeshes.traverse( c => {
if ( c.geometry && ! c.geometry.boundsTree ) {
const elCount = c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count;
c.geometry.groups.forEach( group => {
if ( group.count === Infinity ) {
group.count = elCount - group.start;
}
} );
c.geometry.boundsTree = new MeshBVH( c.geometry );
}
} );
// Compute bounding box of allMeshes
allMeshes.updateWorldMatrix( true, true );
const box = new Box3();
allMeshes.traverse( ( child ) => {
if ( child.isMesh && child.geometry ) {
child.updateWorldMatrix( false, false );
box.expandByObject( child, true );
}
} );
const size = box.getSize( new Vector3() );
const center = box.getCenter( new Vector3() );
console.log( 'Model bounds:', box.min.toArray(), box.max.toArray() );
console.log( 'Model size:', size.toArray(), 'center:', center.toArray() );
// Create white ground plane on top of the bounding box (plus 3m offset)
const planeHeight = box.max.y + 3;
const planeSize = Math.max( size.x, size.z ) * 1.5;
const planeGeometry = new PlaneGeometry( planeSize, planeSize );
const planeMaterial = new MeshBasicMaterial( {
color: 0xffffff,
transparent: true,
opacity: 0.95,
} );
const groundPlane = new Mesh( planeGeometry, planeMaterial );
groundPlane.rotation.x = - Math.PI / 2; // Rotate to be horizontal
groundPlane.position.set( center.x, planeHeight, center.z );
world.scene.three.add( groundPlane );
const clipper = components.get( OBC.Clipper );
// const clipNormal = new Vector3(0, 1, 0).applyEuler(new Euler(Math.PI / 2, 0, 0)).applyEuler(new Euler(Math.PI / 4, Math.PI / 4, 0));
// const planeId = clipper.createFromNormalAndCoplanarPoint(world, new Vector3(0, 1, 0), new Vector3(-50, 50, 0))
// const plane = clipper.list.get(planeId);
// --- Clipping edge projection ---
const clippingEdgeMaterial = new LineBasicMaterial( { color: 0xff0000 } );
const clippingEdgesGroup = new Group();
clippingEdgesGroup.position.y = planeHeight + 0.02;
world.scene.three.add( clippingEdgesGroup );
const intersectingMeshes = new Set();
// create projection display mesh
const projectionMaterial = new LineBasicMaterial( { color: 0x888888 } );
projection = new LineSegments( new BufferGeometry(), projectionMaterial );
projection.position.y = planeHeight + 0.01;
drawThroughProjection = new LineSegments( new BufferGeometry(), new LineDashedMaterial( { color: 0x444444, dashSize: 0.03, gapSize: 0.03, transparent: true } ) );
drawThroughProjection.position.y = planeHeight + 0.01;
drawThroughProjection.renderOrder = - 1;
world.scene.three.add( projection, drawThroughProjection );
gui = new GUI();
gui.add( params, 'includeIntersectionEdges' );
gui.add( params, 'displayDrawThroughProjection' );
gui.add( params, 'enableClipping' );
gui.add( params, 'displayClippingEdges' );
gui.add( params, 'rotate' );
gui.add( params, 'regenerate' );
world.renderer.onBeforeUpdate.add( () => {
drawThroughProjection.visible = params.displayDrawThroughProjection;
clippingEdgesGroup.visible = params.displayClippingEdges;
} );
updateEdges();
async function loadModel( url ) {
const fetched = await fetch( url );
const buffer = await fetched.arrayBuffer();
const model = await fragments.core.load( buffer, {
modelId: url,
camera: world.camera.three,
raw: false,
} );
world.scene.three.add( model.object );
// model.object.rotation.x = Math.PI / 4;
// model.object.rotation.y = Math.PI / 4;
const now = performance.now();
await fragments.core.update( true );
const then = performance.now();
console.log( `Time taken: ${ then - now }ms` );
return model;
}
function generateClippingEdges() {
// Clear previous clipping edges
for ( const child of [ ...clippingEdgesGroup.children ] ) {
clippingEdgesGroup.remove( child );
if ( child.geometry ) child.geometry.dispose();
}
const clipPlane = plane.three;
const generator = new PlanarIntersectionGenerator();
const invMatrix = new Matrix4();
const v = new Vector3();
// Ensure world matrices are up to date
allMeshes.updateWorldMatrix( true, true );
let totalSegments = 0;
intersectingMeshes.clear();
for ( const child of allMeshes.children ) {
if ( ! child.isMesh || ! child.geometry ) continue;
// Transform clip plane to mesh's local space
invMatrix.copy( child.matrixWorld ).invert();
const localPlane = clipPlane.clone().applyMatrix4( invMatrix );
generator.plane.copy( localPlane );
const bvh = child.geometry.boundsTree || child.geometry;
const edgeGeom = generator.generate( bvh );
const posAttr = edgeGeom.getAttribute( 'position' );
if ( ! posAttr || posAttr.count === 0 ) continue;
// This mesh actually has triangles crossing the plane
intersectingMeshes.add( child );
// Transform positions back to world space and project (flatten Y)
const positions = posAttr.array;
for ( let i = 0; i < positions.length; i += 3 ) {
v.set( positions[ i ], positions[ i + 1 ], positions[ i + 2 ] );
v.applyMatrix4( child.matrixWorld );
positions[ i ] = v.x;
positions[ i + 1 ] = 0;
positions[ i + 2 ] = v.z;
}
posAttr.needsUpdate = true;
const line = new LineSegments( edgeGeom, clippingEdgeMaterial );
clippingEdgesGroup.add( line );
totalSegments += posAttr.count / 2;
}
console.log( `Clipping edges: ${totalSegments} line segments from ${clippingEdgesGroup.children.length} meshes (${intersectingMeshes.size} intersecting)` );
}
// --- Boolean clipping ---
function applyClipping() {
// Clear previous clipped meshes
const previous = [ ...clippedMeshes.children ];
for ( const child of previous ) {
clippedMeshes.remove( child );
if ( child.geometry ) child.geometry.dispose();
}
const clipPlane = plane.three;
const n = clipPlane.normal;
// Create clipping box: a large box on the clipped side (above the plane)
const boxSize = Math.max( size.x, size.y, size.z ) * 4;
const clipBoxGeom = new BoxGeometry( boxSize, boxSize, boxSize );
const clipBoxMesh = new Mesh( clipBoxGeom, material );
// Orient and position the box on the negative side of the clip plane
clipBoxMesh.quaternion.setFromUnitVectors( new Vector3( 0, 1, 0 ), n );
const coplanarPoint = new Vector3();
clipPlane.coplanarPoint( coplanarPoint );
clipBoxMesh.position.copy( coplanarPoint ).addScaledVector( n, - boxSize / 2 );
clipBoxMesh.updateMatrixWorld( true );
let clipped = 0, skipped = 0, errors = 0, kept = 0;
for ( const child of allMeshes.children ) {
if ( ! child.isMesh || ! child.geometry ) continue;
// Use the precise intersection test from generateClippingEdges()
if ( ! intersectingMeshes.has( child ) ) {
// No triangles cross the plane — check which side mesh center is on
const meshCenter = new Box3().setFromObject( child, true ).getCenter( new Vector3() );
const dist = clipPlane.distanceToPoint( meshCenter );
if ( dist >= 0 ) {
// Positive side — keep as-is
const keepMesh = new Mesh( child.geometry.clone(), material );
keepMesh.applyMatrix4( child.matrixWorld );
keepMesh.updateMatrixWorld( true );
clippedMeshes.add( keepMesh );
kept ++;
} else {
skipped ++;
}
continue;
}
// Actually straddles the clip plane — boolean DIFFERENCE
try {
child.updateMatrixWorld( true );
const booleanData = {
type: 'DIFFERENCE',
target: child,
operands: [ clipBoxMesh ],
};
const resultGeom = new BufferGeometry();
geometryEngine.getBooleanOperation( resultGeom, booleanData );
// Check if result has vertices
const posAttr = resultGeom.getAttribute( 'position' );
if ( ! posAttr || posAttr.count === 0 ) {
skipped ++;
continue;
}
// Result is in world space, so create mesh with identity transform
const resultMesh = new Mesh( resultGeom, material );
resultMesh.updateMatrixWorld( true );
clippedMeshes.add( resultMesh );
clipped ++;
} catch ( e ) {
console.warn( 'Boolean op error:', e );
// On error, keep the original mesh
const fallbackMesh = new Mesh( child.geometry.clone(), material );
fallbackMesh.applyMatrix4( child.matrixWorld );
fallbackMesh.updateMatrixWorld( true );
clippedMeshes.add( fallbackMesh );
errors ++;
}
}
console.log( `Boolean clipping: clipped=${clipped}, kept=${kept}, skipped=${skipped}, errors=${errors}, total=${allMeshes.children.length}` );
// Build BVHs for clipped meshes
clippedMeshes.traverse( c => {
if ( c.geometry && ! c.geometry.boundsTree ) {
const elCount = c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count;
c.geometry.groups.forEach( group => {
if ( group.count === Infinity ) {
group.count = elCount - group.start;
}
} );
c.geometry.boundsTree = new MeshBVH( c.geometry, { maxLeafSize: 1, strategy: SAH } );
}
} );
// Hide original meshes, show clipped
// allMeshes.visible = false;
// model.object.visible = false;
// clippedMeshes.visible = true;
}
async function updateEdges() {
outputContainer.innerText = 'Generating...';
// dispose the geometry
projection.geometry.dispose();
drawThroughProjection.geometry.dispose();
// initialize an empty geometry
projection.geometry = new BufferGeometry();
drawThroughProjection.geometry = new BufferGeometry();
const timeStart = window.performance.now();
if ( params.enableClipping ) {
generateClippingEdges();
applyClipping();
}
const generator = new ProjectionGenerator( gpuRenderer );
generator.angleThreshold = ANGLE_THRESHOLD;
generator.includeIntersectionEdges = params.includeIntersectionEdges;
// Use clippedMeshes if clipping is enabled, otherwise allMeshes
const meshSource = params.enableClipping ? clippedMeshes : allMeshes;
let input = await new MeshVisibilityCuller( gpuRenderer, { pixelsPerMeter: 0.01 } ).cull( meshSource );
const result = await generator.generate( input, {
onProgress: p => {
outputContainer.innerText = `Generating... ${ ( p * 100 ).toFixed( 1 ) }%`;
},
} );
drawThroughProjection.geometry.dispose();
drawThroughProjection.geometry = result.hiddenEdges.getLineGeometry();
drawThroughProjection.computeLineDistances();
projection.geometry.dispose();
projection.geometry = result.visibleEdges.getLineGeometry();
const trimTime = window.performance.now() - timeStart;
outputContainer.innerText = `Generation time: ${trimTime.toFixed( 2 )}ms`;
}
================================================
FILE: example/edgeProjection.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>three-edge-projection - Projected Edge Generation</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style type="text/css">
html, body {
padding: 0;
margin: 0;
overflow: hidden;
font-family: monospace;
}
canvas {
width: 100%;
height: 100%;
}
#output {
color: #333;
position: absolute;
left: 10px;
bottom: 10px;
white-space: pre;
}
#info {
position: absolute;
top: 0;
width: 100%;
color: #333;
font-family: monospace;
text-align: center;
padding: 5px 0;
}
</style>
</head>
<body>
<div id="info">
Accelerated geometry edge projection and clipping onto<br/>the XZ plane for orthographic vector views.
</div>
<div id="output"></div>
<script type="module" src="./edgeProjection.js"></script>
</body>
</html>
================================================
FILE: example/edgeProjection.js
================================================
import {
Box3,
WebGLRenderer,
Scene,
DirectionalLight,
AmbientLight,
Group,
BufferGeometry,
LineSegments,
LineBasicMaterial,
PerspectiveCamera,
} from 'three';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { LDrawLoader } from 'three/examples/jsm/loaders/LDrawLoader.js';
import { LDrawConditionalLineMaterial } from 'three/examples/jsm/materials/LDrawConditionalLineMaterial.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { ProjectionGenerator, MeshVisibilityCuller } from '..';
import { MeshBVH, SAH } from 'three-mesh-bvh';
const params = {
displayModel: true,
displayDrawThroughProjection: false,
includeIntersectionEdges: true,
visibilityCullMeshes: false,
rotate: () => {
group.quaternion.random();
group.position.set( 0, 0, 0 );
group.updateMatrixWorld( true );
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
group.updateMatrixWorld( true );
needsRender = true;
task = updateEdges();
},
regenerate: () => {
task = updateEdges();
},
};
const ANGLE_THRESHOLD = 50;
let needsRender = false;
let renderer, camera, scene, gui, controls;
let model, projection, drawThroughProjection, group;
let outputContainer;
let task = null;
init();
async function init() {
outputContainer = document.getElementById( 'output' );
const bgColor = 0xeeeeee;
// renderer setup
renderer = new WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setClearColor( bgColor, 1 );
document.body.appendChild( renderer.domElement );
// scene setup
scene = new Scene();
// lights
const light = new DirectionalLight( 0xffffff, 3.5 );
light.position.set( 1, 2, 3 );
scene.add( light );
const ambientLight = new AmbientLight( 0xb0bec5, 0.5 );
scene.add( ambientLight );
// load model
group = new Group();
scene.add( group );
window.ROOT = group;
if ( window.location.hash === '#lego' ) {
// init loader
const loader = new LDrawLoader();
loader.setConditionalLineMaterial( LDrawConditionalLineMaterial );
await loader.preloadMaterials( 'https://raw.githubusercontent.com/gkjohnson/ldraw-parts-library/master/colors/ldcfgalt.ldr' );
// load model
model = await loader
.setPartsLibraryPath( 'https://raw.githubusercontent.com/gkjohnson/ldraw-parts-library/master/complete/ldraw/' )
.loadAsync( 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/models/ldraw/officialLibrary/models/1621-1-LunarMPVVehicle.mpd_Packed.mpd' );
// adjust model transforms
model.scale.setScalar( 0.01 );
model.rotation.x = Math.PI;
// remove lines
const toRemove = [];
model.traverse( c => {
if ( c.isLine ) {
toRemove.push( c );
}
} );
toRemove.forEach( c => {
c.removeFromParent();
} );
} else {
const gltf = await new GLTFLoader()
.setMeshoptDecoder( MeshoptDecoder )
.loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' );
model = gltf.scene;
}
// initialize BVHs
model.traverse( c => {
if ( c.geometry && ! c.geometry.boundsTree ) {
const elCount = c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count;
c.geometry.groups.forEach( group => {
if ( group.count === Infinity ) {
group.count = elCount - group.start;
}
} );
c.geometry.boundsTree = new MeshBVH( c.geometry, { maxLeafSize: 1, strategy: SAH } );
}
} );
// center model
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
group.add( model );
group.updateMatrixWorld( true );
// create projection display mesh
projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0x030303, depthWrite: false } ) );
drawThroughProjection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0xcacaca, depthWrite: false } ) );
drawThroughProjection.renderOrder = - 1;
scene.add( projection, drawThroughProjection );
// camera setup
camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 1e3 );
camera.position.setScalar( 3.5 );
camera.updateProjectionMatrix();
needsRender = true;
// controls
controls = new OrbitControls( camera, renderer.domElement );
controls.addEventListener( 'change', () => {
needsRender = true;
} );
gui = new GUI();
gui.add( params, 'displayModel' ).onChange( () => needsRender = true );
gui.add( params, 'displayDrawThroughProjection' ).onChange( () => needsRender = true );
gui.add( params, 'includeIntersectionEdges' );
gui.add( params, 'visibilityCullMeshes' );
gui.add( params, 'rotate' );
gui.add( params, 'regenerate' ).onChange( () => needsRender = true );
render();
task = updateEdges();
window.addEventListener( 'resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
needsRender = true;
}, false );
}
async function* updateEdges( runTime = 30 ) {
outputContainer.innerText = 'Generating...';
// dispose the geometry
projection.geometry.dispose();
drawThroughProjection.geometry.dispose();
// initialize an empty geometry
projection.geometry = new BufferGeometry();
drawThroughProjection.geometry = new BufferGeometry();
const timeStart = window.performance.now();
const generator = new ProjectionGenerator();
generator.iterationTime = runTime;
generator.angleThreshold = ANGLE_THRESHOLD;
generator.includeIntersectionEdges = params.includeIntersectionEdges;
let input = [ model ];
if ( params.visibilityCullMeshes ) {
input = await new MeshVisibilityCuller( renderer, { pixelsPerMeter: 0.01 } ).cull( input );
}
const collection = yield* generator.generate( input, {
onProgress: ( tot, msg, edges ) => {
outputContainer.innerText = msg;
if ( tot ) outputContainer.innerText += ' ' + ( 100 * tot ).toFixed( 1 ) + '%';
if ( edges ) {
projection.geometry.dispose();
projection.geometry = edges.visibleEdges.getLineGeometry();
needsRender = true;
}
},
} );
drawThroughProjection.geometry.dispose();
drawThroughProjection.geometry = collection.hiddenEdges.getLineGeometry();
projection.geometry.dispose();
projection.geometry = collection.visibleEdges.getLineGeometry();
const geometry = projection.geometry;
const trimTime = window.performance.now() - timeStart;
projection.geometry.dispose();
projection.geometry = geometry;
outputContainer.innerText = `Generation time: ${ trimTime.toFixed( 2 ) }ms`;
needsRender = true;
}
function render() {
requestAnimationFrame( render );
if ( task ) {
const res = task.next();
if ( res.done ) {
task = null;
}
}
model.visible = params.displayModel;
drawThroughProjection.visible = params.displayDrawThroughProjection;
if ( needsRender ) {
renderer.render( scene, camera );
needsRender = false;
}
}
================================================
FILE: example/edgeProjectionWebGPU.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>three-edge-projection - WebGPU Projected Edge Generation</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style type="text/css">
html, body {
padding: 0;
margin: 0;
overflow: hidden;
font-family: monospace;
}
canvas {
width: 100%;
height: 100%;
}
#output {
color: #333;
position: absolute;
left: 10px;
bottom: 10px;
white-space: pre;
}
#info {
position: absolute;
top: 0;
width: 100%;
color: #333;
font-family: monospace;
text-align: center;
padding: 5px 0;
}
</style>
</head>
<body>
<div id="info">
WebGPU accelerated geometry edge projection and clipping onto<br/>the XZ plane for orthographic vector views.
</div>
<div id="output"></div>
<script type="module" src="./edgeProjectionWebGPU.js"></script>
</body>
</html>
================================================
FILE: example/edgeProjectionWebGPU.js
================================================
import {
Box3,
Scene,
DirectionalLight,
AmbientLight,
Group,
BufferGeometry,
BufferAttribute,
LineSegments,
LineBasicMaterial,
PerspectiveCamera,
WebGPURenderer,
} from 'three/webgpu';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { ProjectionGenerator, MeshVisibilityCuller } from 'three-edge-projection/webgpu';
import { Color } from 'three';
const params = {
displayModel: true,
displayDrawThroughProjection: false,
includeIntersectionEdges: false,
visibilityCullMeshes: false,
perObjectColors: false,
regenerate: () => {
updateEdges();
},
rotate: () => {
group.quaternion.random();
group.position.set( 0, 0, 0 );
group.updateMatrixWorld( true );
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
group.updateMatrixWorld( true );
needsRender = true;
},
};
let needsRender = false;
let renderer, camera, scene, gui, controls;
let model, projection, drawThroughProjection, group;
let outputContainer;
let abortController;
init();
async function init() {
outputContainer = document.getElementById( 'output' );
const bgColor = 0xeeeeee;
// renderer setup
renderer = new WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setClearColor( bgColor, 1 );
await renderer.init();
document.body.appendChild( renderer.domElement );
// scene setup
scene = new Scene();
// lights
const light = new DirectionalLight( 0xffffff, 3.5 );
light.position.set( 1, 2, 3 );
scene.add( light );
const ambientLight = new AmbientLight( 0xb0bec5, 0.5 );
scene.add( ambientLight );
// load model
group = new Group();
scene.add( group );
const gltf = await new GLTFLoader()
.setMeshoptDecoder( MeshoptDecoder )
.loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' );
model = gltf.scene;
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
group.add( model );
group.updateMatrixWorld( true );
// create projection display meshes
projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) );
drawThroughProjection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) );
drawThroughProjection.renderOrder = - 1;
scene.add( projection, drawThroughProjection );
// camera setup
camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 1e6 );
camera.position.setScalar( 3.5 );
camera.updateProjectionMatrix();
needsRender = true;
// controls
controls = new OrbitControls( camera, renderer.domElement );
controls.addEventListener( 'change', () => {
needsRender = true;
} );
gui = new GUI();
const displayFolder = gui.addFolder( 'Display' );
displayFolder.add( params, 'displayModel' ).onChange( () => needsRender = true );
displayFolder.add( params, 'displayDrawThroughProjection' ).onChange( () => needsRender = true );
const projectionFolder = gui.addFolder( 'Projection' );
projectionFolder.add( params, 'includeIntersectionEdges' );
projectionFolder.add( params, 'visibilityCullMeshes' );
projectionFolder.add( params, 'perObjectColors' );
projectionFolder.add( params, 'rotate' );
projectionFolder.add( params, 'regenerate' );
render();
updateEdges();
window.addEventListener( 'resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
needsRender = true;
}, false );
}
async function updateEdges() {
if ( abortController ) {
abortController.abort();
}
abortController = new AbortController();
projection.geometry.dispose();
projection.material.dispose();
projection.geometry = new BufferGeometry();
drawThroughProjection.geometry.dispose();
drawThroughProjection.material.dispose();
drawThroughProjection.geometry = new BufferGeometry();
needsRender = true;
const timeStart = window.performance.now();
const generator = new ProjectionGenerator( renderer );
generator.includeIntersectionEdges = params.includeIntersectionEdges;
model.visible = true;
let input = [ model ];
if ( params.visibilityCullMeshes ) {
input = await new MeshVisibilityCuller( renderer, { pixelsPerMeter: 0.1 } ).cull( input );
}
let result;
try {
result = await generator.generate( input, {
signal: abortController.signal,
onProgress: ( p, msg ) => {
outputContainer.innerText = `${ msg }... ${ ( p * 100 ).toFixed( 2 ) }%`;
},
} );
} catch {
// cancelled
return;
}
const visGeom = result.visibleEdges.getLineGeometry();
const hidGeom = result.hiddenEdges.getLineGeometry();
if ( params.perObjectColors ) {
applyPerObjectColors( result.visibleEdges, visGeom );
applyPerObjectColors( result.hiddenEdges, hidGeom, 0.8 );
}
projection.geometry.dispose();
projection.material.dispose();
projection.geometry = visGeom;
projection.material.vertexColors = params.perObjectColors;
projection.material.color.set( params.perObjectColors ? 0xffffff : 0x030303 );
drawThroughProjection.geometry.dispose();
drawThroughProjection.material.dispose();
drawThroughProjection.geometry = hidGeom;
drawThroughProjection.material.vertexColors = params.perObjectColors;
drawThroughProjection.material.color.set( params.perObjectColors ? 0xffffff : 0xcacaca );
const elapsed = window.performance.now() - timeStart;
outputContainer.innerText = `Generation time: ${ elapsed.toFixed( 2 ) }ms`;
needsRender = true;
}
function applyPerObjectColors( edgeSet, geometry, lightness = 0.5 ) {
const totalVertices = geometry.attributes.position.count;
const colorArray = new Float32Array( totalVertices * 3 );
const color = new Color();
for ( const mesh of edgeSet.meshToSegments.keys() ) {
const range = edgeSet.getRangeForMesh( mesh );
if ( ! range ) continue;
color.setHSL( Math.random(), 0.75, lightness );
for ( let i = range.start; i < range.start + range.count; i ++ ) {
colorArray[ i * 3 + 0 ] = color.r;
colorArray[ i * 3 + 1 ] = color.g;
colorArray[ i * 3 + 2 ] = color.b;
}
}
geometry.setAttribute( 'color', new BufferAttribute( colorArray, 3 ) );
}
function render() {
requestAnimationFrame( render );
model.visible = params.displayModel;
drawThroughProjection.visible = params.displayDrawThroughProjection;
if ( needsRender ) {
renderer.render( scene, camera );
needsRender = false;
}
}
================================================
FILE: example/floorProjection.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>three-edge-projection - Projected Edge Generation</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style type="text/css">
html, body {
padding: 0;
margin: 0;
overflow: hidden;
font-family: monospace;
background-color: #111;
}
canvas {
width: 100%;
height: 100%;
}
#output {
color: white;
position: absolute;
left: 10px;
bottom: 10px;
white-space: pre;
}
#info {
position: absolute;
top: 0;
width: 100%;
color: white;
font-family: monospace;
text-align: center;
padding: 5px 0;
}
a {
color: white;
}
</style>
</head>
<body>
<div id="info">
Floor plan silhouette and edge projection from <a href="https://sketchfab.com/3d-models/3d-floor-plan-of-home-apartment-office-layout-506ed4379b5940a793e652c1b7c0d836">Sketchfab model</a>.
</div>
<div id="output">loading...</div>
<script type="module" src="./floorProjection.js"></script>
</body>
</html>
================================================
FILE: example/floorProjection.js
================================================
import {
Box3,
WebGLRenderer,
Scene,
DirectionalLight,
AmbientLight,
Group,
BufferGeometry,
LineSegments,
LineBasicMaterial,
PerspectiveCamera,
MeshBasicMaterial,
Mesh,
DoubleSide,
} from 'three';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { ProjectionGenerator, SilhouetteGenerator } from '../src';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
const ANGLE_THRESHOLD = 50;
let renderer, camera, scene, gui, controls;
let model, outlines, group, silhouette;
let outputContainer;
let task = null;
const params = {
displayModel: false,
regenerate: () => {
task = updateProjection();
},
};
init();
async function init() {
outputContainer = document.getElementById( 'output' );
const bgColor = 0x111111;
// renderer setup
renderer = new WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setClearColor( bgColor, 1 );
document.body.appendChild( renderer.domElement );
// scene setup
scene = new Scene();
// lights
const light = new DirectionalLight( 0xffffff, 3.5 );
light.position.set( 1, 2, 3 );
scene.add( light );
const ambientLight = new AmbientLight( 0xb0bec5, 0.5 );
scene.add( ambientLight );
// load model
group = new Group();
scene.add( group );
const gltf = await new GLTFLoader()
.setMeshoptDecoder( MeshoptDecoder )
.loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/3d-home-layout/scene.glb' );
model = gltf.scene;
group.updateMatrixWorld( true );
// center model
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
group.add( model );
model.visible = false;
// create projection display mesh
silhouette = new Mesh( new BufferGeometry(), new MeshBasicMaterial( {
color: '#eee',
polygonOffset: true,
polygonOffsetFactor: 3,
polygonOffsetUnits: 3,
side: DoubleSide,
} ) );
outlines = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0x030303 } ) );
scene.add( outlines, silhouette );
// camera setup
camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 50 );
camera.position.setScalar( 5.5 );
camera.updateProjectionMatrix();
// controls
controls = new MapControls( camera, renderer.domElement );
controls.zoomToCursor = true;
controls.maxPolarAngle = Math.PI / 3;
task = updateProjection();
gui = new GUI();
gui.add( params, 'displayModel' );
gui.add( params, 'regenerate' );
render();
window.addEventListener( 'resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}, false );
}
function* updateProjection() {
outputContainer.innerText = 'processing: --';
silhouette.visible = false;
outlines.visible = false;
// transform and merge geometries to project into a single model
const geometries = [];
model.updateWorldMatrix( true, true );
model.traverse( c => {
if ( c.geometry ) {
const clone = c.geometry.clone();
clone.applyMatrix4( c.matrixWorld );
for ( const key in clone.attributes ) {
if ( key !== 'position' ) {
clone.deleteAttribute( key );
}
}
geometries.push( clone );
}
} );
const mergedGeometry = mergeGeometries( geometries, false );
yield;
// generate the silhouette
let task, result, generator;
generator = new SilhouetteGenerator();
generator.sortTriangles = true;
task = generator.generate( mergedGeometry, {
onProgress: ( p, data ) => {
outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`;
silhouette.geometry.dispose();
silhouette.geometry = data.getGeometry();
silhouette.visible = true;
},
} );
result = task.next();
while ( ! result.done ) {
result = task.next();
yield;
}
silhouette.geometry.dispose();
silhouette.geometry = result.value;
silhouette.visible = true;
outputContainer.innerText = 'generating intersection edges...';
// generate the edges
generator = new ProjectionGenerator();
generator.angleThreshold = ANGLE_THRESHOLD;
task = generator.generate( mergedGeometry, {
onProgress: ( p, data ) => {
outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`;
outlines.geometry.dispose();
outlines.geometry = data.visibleEdges.getLineGeometry();
outlines.visible = true;
},
} );
result = task.next();
while ( ! result.done ) {
result = task.next();
yield;
}
outlines.geometry.dispose();
outlines.geometry = result.value;
outlines.visible = true;
outputContainer.innerText = '';
}
function render() {
requestAnimationFrame( render );
if ( task ) {
const res = task.next();
if ( res.done ) {
task = null;
}
}
model.visible = params.displayModel;
renderer.render( scene, camera );
}
================================================
FILE: example/perspectiveProjection.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>three-edge-projection - Perspective Camera Projection</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style type="text/css">
html, body {
padding: 0;
margin: 0;
overflow: hidden;
font-family: monospace;
}
canvas {
width: 100%;
height: 100%;
}
#output {
color: #333;
position: absolute;
left: 10px;
bottom: 10px;
white-space: pre;
}
#info {
position: absolute;
top: 0;
width: 100%;
color: #333;
font-family: monospace;
text-align: center;
padding: 5px 0;
}
</style>
</head>
<body>
<div id="info">
Perspective camera edge projection - position the camera then click Generate.
<br/>
Note that compute shader-based generation can result in artifacts due to floating point precision.
</div>
<div id="output"></div>
<script type="module" src="./perspectiveProjection.js"></script>
</body>
</html>
================================================
FILE: example/perspectiveProjection.js
================================================
import {
Box3,
Scene,
DirectionalLight,
AmbientLight,
Group,
BufferGeometry,
LineSegments,
LineBasicMaterial,
PerspectiveCamera,
WebGPURenderer,
Vector3,
} from 'three/webgpu';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { ProjectionGenerator as ProjectionGeneratorCompute } from 'three-edge-projection/webgpu';
import { ProjectionGenerator } from 'three-edge-projection';
const params = {
displayModel: true,
webGPU: false,
displayDrawThroughProjection: false,
includeIntersectionEdges: false,
regenerate: () => {
updateEdges();
},
};
let needsRender = false;
let renderer, camera, scene, gui, controls;
let model, projection, drawThroughProjection, group, projectionGroup;
let outputContainer;
let abortController;
init();
async function init() {
outputContainer = document.getElementById( 'output' );
const bgColor = 0xeeeeee;
// renderer setup
renderer = new WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setClearColor( bgColor, 1 );
await renderer.init();
document.body.appendChild( renderer.domElement );
// scene setup
scene = new Scene();
// lights
const light = new DirectionalLight( 0xffffff, 3.5 );
light.position.set( 1, 2, 3 );
scene.add( light );
const ambientLight = new AmbientLight( 0xb0bec5, 0.5 );
scene.add( ambientLight );
// load model
group = new Group();
scene.add( group );
const gltf = await new GLTFLoader()
.setMeshoptDecoder( MeshoptDecoder )
.loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' );
model = gltf.scene;
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y );
group.add( model );
group.updateMatrixWorld( true );
// create projection display meshes
projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) );
drawThroughProjection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { depthWrite: false } ) );
drawThroughProjection.renderOrder = - 1;
projectionGroup = new Group();
projectionGroup.add( projection, drawThroughProjection );
scene.add( projectionGroup );
// camera setup
camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 100 );
camera.position.setScalar( 3.5 );
camera.updateProjectionMatrix();
needsRender = true;
// controls
controls = new OrbitControls( camera, renderer.domElement );
controls.addEventListener( 'change', () => {
needsRender = true;
} );
gui = new GUI();
const displayFolder = gui.addFolder( 'Display' );
displayFolder.add( params, 'displayModel' ).onChange( () => needsRender = true ).listen();
displayFolder.add( params, 'displayDrawThroughProjection' ).onChange( () => needsRender = true );
const generationFolder = gui.addFolder( 'Generation' );
generationFolder.add( params, 'webGPU' );
generationFolder.add( params, 'includeIntersectionEdges' );
generationFolder.add( params, 'regenerate' );
render();
updateEdges();
window.addEventListener( 'resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
needsRender = true;
}, false );
}
async function updateEdges() {
if ( abortController ) {
abortController.abort();
}
abortController = new AbortController();
projection.geometry.dispose();
projection.material.dispose();
projection.geometry = new BufferGeometry();
drawThroughProjection.geometry.dispose();
drawThroughProjection.material.dispose();
drawThroughProjection.geometry = new BufferGeometry();
needsRender = true;
const timeStart = window.performance.now();
// position the projectionGroup to map NDC output back to a camera-facing plane
const FWD = new Vector3( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
const distToCenter = - FWD.dot( camera.position ) + 1.75;
const _v = new Vector3( 1, 1, 1 ).applyMatrix4( camera.projectionMatrixInverse );
_v.multiplyScalar( distToCenter / _v.z );
projectionGroup.rotation.copy( camera.rotation ).reorder( 'ZYX' );
projectionGroup.rotation.x += Math.PI / 2;
projectionGroup.scale.set( _v.x, 1, _v.y );
projectionGroup.position.copy( camera.position ).addScaledVector( FWD, distToCenter );
// construct the generation group — encodes camera VP matrix so the generator
// projects along the camera's view direction instead of world Y
const scaleGroup = new Group();
const perspectiveGroup = new Group();
perspectiveGroup.matrixAutoUpdate = false;
scaleGroup.add( perspectiveGroup );
model.visible = true;
const clone = group.clone();
perspectiveGroup.add( clone );
clone.matrix
.multiplyMatrices( camera.matrixWorldInverse, group.matrixWorld )
.decompose( clone.position, clone.quaternion, clone.scale );
perspectiveGroup.matrix.copy( camera.projectionMatrix );
scaleGroup.scale.x = - 1;
scaleGroup.rotation.x = Math.PI / 2;
scaleGroup.updateMatrixWorld( true );
// normalize scale so geometry is in a workable range for the GPU
const box = new Box3();
box.setFromObject( perspectiveGroup );
scaleGroup.scale.z = 5 / ( box.max.y - box.min.y );
scaleGroup.position.y = - box.min.y * scaleGroup.scale.z - 0.5;
scaleGroup.updateMatrixWorld( true );
const input = [ clone ];
let result;
try {
const onProgress = ( p, msg ) => {
outputContainer.innerText = `${ msg }... ${ ( p * 100 ).toFixed( 2 ) }%`;
};
const options = {
signal: abortController.signal,
onProgress,
};
if ( params.webGPU ) {
const generator = new ProjectionGeneratorCompute( renderer );
generator.includeIntersectionEdges = params.includeIntersectionEdges;
result = await generator.generate( input, options );
} else {
const generator = new ProjectionGenerator();
generator.includeIntersectionEdges = params.includeIntersectionEdges;
result = await generator.generateAsync( input, options );
}
} catch {
// cancelled
return;
}
const visGeom = result.visibleEdges.getLineGeometry();
const hidGeom = result.hiddenEdges.getLineGeometry();
projection.geometry.dispose();
projection.material.dispose();
projection.geometry = visGeom;
projection.material = new LineBasicMaterial( { color: 0x030303, depthWrite: false } );
drawThroughProjection.geometry.dispose();
drawThroughProjection.material.dispose();
drawThroughProjection.geometry = hidGeom;
drawThroughProjection.material = new LineBasicMaterial( { color: 0xcacaca, depthWrite: false } );
const elapsed = window.performance.now() - timeStart;
outputContainer.innerText = `Generation time: ${ elapsed.toFixed( 2 ) }ms`;
needsRender = true;
}
function render() {
requestAnimationFrame( render );
model.visible = params.displayModel;
drawThroughProjection.visible = params.displayDrawThroughProjection;
if ( needsRender ) {
renderer.render( scene, camera );
needsRender = false;
}
}
================================================
FILE: example/planarIntersection.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>three-edge-projection - Projected Edge Generation</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style type="text/css">
html, body {
padding: 0;
margin: 0;
overflow: hidden;
font-family: monospace;
}
canvas {
width: 100%;
height: 100%;
}
#output {
color: #eee;
position: absolute;
left: 10px;
bottom: 10px;
white-space: pre;
}
#info {
position: absolute;
top: 0;
width: 100%;
color: #eee;
font-family: monospace;
text-align: center;
padding: 5px 0;
}
</style>
</head>
<body>
<div id="info">
Accelerated planar edge intersection.
</div>
<div id="output"></div>
<script type="module" src="./planarIntersection.js"></script>
</body>
</html>
================================================
FILE: example/planarIntersection.js
================================================
import {
Box3,
WebGLRenderer,
Scene,
DirectionalLight,
AmbientLight,
Group,
MeshBasicMaterial,
BufferGeometry,
LineSegments,
LineBasicMaterial,
PerspectiveCamera,
Mesh,
PlaneGeometry,
DoubleSide,
} from 'three';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import { PlanarIntersectionGenerator } from '..';
import { MeshBVH } from 'three-mesh-bvh';
const params = {
displayModel: true,
planePosition: 1,
};
let renderer, camera, scene, gui, controls, bvh;
let model, projection, group, plane;
let outputContainer;
init();
async function init() {
outputContainer = document.getElementById( 'output' );
const bgColor = 0x111111;
// renderer setup
renderer = new WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setClearColor( bgColor, 1 );
renderer.clear();
document.body.appendChild( renderer.domElement );
// scene setup
scene = new Scene();
// lights
const light = new DirectionalLight( 0xffffff, 3.5 );
light.position.set( 1, 2, 3 );
scene.add( light );
const ambientLight = new AmbientLight( 0xb0bec5, 0.5 );
scene.add( ambientLight );
// load model
group = new Group();
scene.add( group );
const gltf = await new GLTFLoader()
.setMeshoptDecoder( MeshoptDecoder )
.loadAsync( 'https://raw.githubusercontent.com/gkjohnson/3d-demo-data/main/models/nasa-m2020/Perseverance.glb' );
model = gltf.scene;
// generate the merged geometry
const geometries = [];
model.updateWorldMatrix( true, true );
model.traverse( c => {
if ( c.geometry ) {
const clone = c.geometry.clone();
clone.applyMatrix4( c.matrixWorld );
for ( const key in clone.attributes ) {
if ( key !== 'position' ) {
clone.deleteAttribute( key );
}
}
geometries.push( clone );
}
} );
const mergedGeometry = mergeGeometries( geometries, false );
bvh = new MeshBVH( mergedGeometry, { maxLeafSize: 1 } );
// center model
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
group.add( model );
// create plane to display the cut location
plane = new Mesh( new PlaneGeometry( 5, 5 ), new MeshBasicMaterial( { color: 0x333333, transparent: true, opacity: 0.5, side: DoubleSide } ) );
plane.rotation.x = - Math.PI / 2;
group.add( plane );
// create projection display mesh
projection = new LineSegments( new BufferGeometry(), new LineBasicMaterial( { color: 0xeeeeee } ) );
projection.scale.y = 0;
scene.add( projection );
// camera setup
camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 50 );
camera.position.setScalar( 3.5 );
camera.updateProjectionMatrix();
// controls
controls = new OrbitControls( camera, renderer.domElement );
updateLines();
gui = new GUI();
gui.add( params, 'displayModel' );
gui.add( params, 'planePosition', 0, 2.5 ).onChange( () => updateLines() );
render();
window.addEventListener( 'resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}, false );
}
function updateLines() {
projection.geometry.dispose();
const generator = new PlanarIntersectionGenerator();
generator.plane.constant = - params.planePosition;
let start, delta;
start = performance.now();
projection.geometry = generator.generate( bvh );
delta = performance.now() - start;
outputContainer.innerText = `${ delta.toFixed( 2 ) }ms`;
}
function render() {
requestAnimationFrame( render );
group.visible = params.displayModel;
projection.visible = params.displayProjection;
plane.position.y = params.planePosition;
renderer.render( scene, camera );
}
================================================
FILE: example/silhouetteProjection.html
================================================
<!DOCTYPE html>
<html>
<head>
<title>three-edge-projection - Projected Edge Generation</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<style type="text/css">
html, body {
padding: 0;
margin: 0;
overflow: hidden;
font-family: monospace;
}
canvas {
width: 100%;
height: 100%;
}
#output {
color: #333;
position: absolute;
left: 10px;
bottom: 10px;
white-space: pre;
}
#info {
position: absolute;
top: 0;
width: 100%;
color: #333;
font-family: monospace;
text-align: center;
padding: 5px 0;
}
</style>
</head>
<body>
<div id="info">
Projected silhouette generation using the "clipper2-js" package.
</div>
<div id="output"></div>
<script type="module" src="./silhouetteProjection.js"></script>
</body>
</html>
================================================
FILE: example/silhouetteProjection.js
================================================
import {
Box3,
WebGLRenderer,
Scene,
DirectionalLight,
AmbientLight,
Group,
MeshStandardMaterial,
MeshBasicMaterial,
PerspectiveCamera,
Mesh,
TorusKnotGeometry,
DoubleSide,
LineSegments,
LineBasicMaterial,
} from 'three';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { OUTPUT_BOTH, SilhouetteGenerator } from '../src';
import { SilhouetteGeneratorWorker } from '../src/worker/SilhouetteGeneratorWorker.js';
const params = {
displaySilhouette: true,
displayWireframe: false,
displayOutline: false,
displayModel: true,
useWorker: false,
rotate: () => {
group.quaternion.random();
group.position.set( 0, 0, 0 );
group.updateMatrixWorld( true );
const box = new Box3();
box.setFromObject( model, true );
box.getCenter( group.position ).multiplyScalar( - 1 );
group.position.y = Math.max( 0, - box.min.y ) + 1;
},
regenerate: () => {
task = updateEdges();
},
};
let renderer, camera, scene, gui, controls;
let model, projection, projectionWireframe, group, edges;
let outputContainer;
let worker;
let task = null;
init();
async function init() {
outputContainer = document.getElementById( 'output' );
const bgColor = 0xeeeeee;
// renderer setup
renderer = new WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.setClearColor( bgColor, 1 );
document.body.appendChild( renderer.domElement );
// scene setup
scene = new Scene();
// lights
const light = new DirectionalLight( 0xffffff, 3.5 );
light.position.set( 1, 2, 3 );
scene.add( light );
const ambientLight = new AmbientLight( 0xb0bec5, 0.5 );
scene.add( ambientLight );
// load model
group = new Group();
group.position.y = 2;
scene.add( group );
model = new Mesh( new TorusKnotGeometry( 1, 0.4, 120, 30 ), new MeshStandardMaterial( {
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
} ) );
model.rotation.set( Math.PI / 4, 0, Math.PI / 8 );
group.add( model );
// create projection display mesh
projection = new Mesh( undefined, new MeshBasicMaterial( {
color: 0xf06292,
side: DoubleSide,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1,
} ) );
projection.position.y = - 2;
scene.add( projection );
edges = new LineSegments( undefined, new LineBasicMaterial( { color: 0 } ) );
edges.position.y = - 2;
scene.add( edges );
projectionWireframe = new Mesh( undefined, new MeshBasicMaterial( { color: 0xc2185b, wireframe: true } ) );
projectionWireframe.position.y = - 2;
scene.add( projectionWireframe );
// camera setup
camera = new PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.01, 50 );
camera.position.setScalar( 4.5 );
camera.updateProjectionMatrix();
// controls
controls = new OrbitControls( camera, renderer.domElement );
gui = new GUI();
gui.add( params, 'displayModel' );
gui.add( params, 'displaySilhouette' );
gui.add( params, 'displayOutline' );
gui.add( params, 'displayWireframe' );
gui.add( params, 'useWorker' );
gui.add( params, 'rotate' );
gui.add( params, 'regenerate' );
worker = new SilhouetteGeneratorWorker();
task = updateEdges();
render();
window.addEventListener( 'resize', function () {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}, false );
}
function* updateEdges( runTime = 30 ) {
outputContainer.innerText = 'processing: --';
// transform and merge geometries to project into a single model
let timeStart = window.performance.now();
const geometries = [];
model.updateWorldMatrix( true, true );
model.traverse( c => {
if ( c.geometry ) {
const clone = c.geometry.clone();
clone.applyMatrix4( c.matrixWorld );
for ( const key in clone.attributes ) {
if ( key !== 'position' ) {
clone.deleteAttribute( key );
}
}
geometries.push( clone );
}
} );
const mergedGeometry = mergeGeometries( geometries, false );
const mergeTime = window.performance.now() - timeStart;
yield;
// generate the candidate edges
timeStart = window.performance.now();
let result = null;
if ( ! params.useWorker ) {
const generator = new SilhouetteGenerator();
generator.iterationTime = runTime;
generator.output = OUTPUT_BOTH;
const task = generator.generate( mergedGeometry, {
onProgress: ( p, info ) => {
outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`;
const result = info.getGeometry();
projection.geometry.dispose();
projection.geometry = result[ 0 ];
projectionWireframe.geometry = result[ 0 ];
edges.geometry.dispose();
edges.geometry = result[ 1 ];
if ( params.displaySilhouette || params.displayWireframe || params.displayOutline ) {
projection.geometry.dispose();
projection.geometry = result[ 0 ];
projectionWireframe.geometry = result[ 0 ];
edges.geometry.dispose();
edges.geometry = result[ 1 ];
}
},
} );
let res = task.next();
while ( ! res.done ) {
res = task.next();
yield;
}
result = res.value;
} else {
worker
.generate( mergedGeometry, {
output: OUTPUT_BOTH,
onProgress: p => {
outputContainer.innerText = `processing: ${ parseFloat( ( p * 100 ).toFixed( 2 ) ) }%`;
},
} )
.then( res => {
result = res;
} );
while ( result === null ) {
yield;
}
}
const trimTime = window.performance.now() - timeStart;
projection.geometry.dispose();
projection.geometry = result[ 0 ];
projectionWireframe.geometry = result[ 0 ];
edges.geometry.dispose();
edges.geometry = result[ 1 ];
outputContainer.innerText =
`merge geometry : ${ mergeTime.toFixed( 2 ) }ms\n` +
`edge trimming : ${ trimTime.toFixed( 2 ) }ms\n` +
`triangles : ${ projection.geometry.index.count / 3 } tris`;
}
function render() {
requestAnimationFrame( render );
if ( task ) {
const res = task.next();
if ( res.done ) {
task = null;
}
}
model.visible = params.displayModel;
projection.visible = params.displaySilhouette;
projectionWireframe.visible = params.displayWireframe;
edges.visible = params.displayOutline;
renderer.render( scene, camera );
}
================================================
FILE: package.json
================================================
{
"name": "three-edge-projection",
"version": "0.0.9",
"description": "",
"type": "module",
"main": "src/index.js",
"exports": {
".": {
"import": "./src/index.js"
},
"./worker": "./src/worker/index.js",
"./webgpu": "./src/webgpu/index.js",
"./src/*": "./src/*"
},
"scripts": {
"start": "vite --config ./vite.config.js",
"build-examples": "vite build --config ./vite.config.js",
"docs:build": "node utils/docs/build.js",
"lint": "eslint .",
"test": "vitest run"
},
"files": [
"src/*"
],
"keywords": [
"graphics",
"tree",
"bounds",
"threejs",
"three-js",
"bounds-hierarchy",
"performance",
"geometry",
"mesh",
"acceleration",
"projection",
"edges"
],
"author": "Garrett Johnson <garrett.kjohnson@gmail.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/gkjohnson/three-edge-projection.git"
},
"bugs": {
"url": "https://github.com/gkjohnson/three-edge-projection/issues"
},
"homepage": "https://github.com/gkjohnson/three-edge-projection#readme",
"peerDependencies": {
"clipper2-js": "^0.9.0",
"three": "^0.155.0",
"three-mesh-bvh": "^0.6.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@thatopen/components": "^3.3.3",
"@thatopen/fragments": "^3.3.7",
"@vitest/eslint-plugin": "^1.1.22",
"eslint": "^9.0.0",
"eslint-config-mdcs": "^5.0.0",
"eslint-plugin-jsdoc": "^62.8.1",
"globals": "^16.5.0",
"jsdoc": "^4.0.5",
"three": ">=0.184.0",
"three-mesh-bvh": ">=0.9.9",
"typescript-eslint": "^8.48.1",
"vite": "^6.2.2",
"vitest": "^3.0.0",
"web-ifc": "^0.0.77"
}
}
================================================
FILE: src/EdgeGenerator.js
================================================
import { Vector3, Matrix4 } from 'three';
import { MeshBVH } from 'three-mesh-bvh';
import { generateEdges } from './utils/generateEdges.js';
import { generateIntersectionEdges } from './utils/generateIntersectionEdges.js';
import { getAllMeshes } from './utils/getAllMeshes.js';
import { nextFrame } from './utils/nextFrame.js';
const _BtoA = /* @__PURE__ */ new Matrix4();
// Class for generating edges for use with the projection generator. Functions take geometries or
// Object3D instances. If an Object3D is passed then lines for all child meshes will be generated
// in world space
// TODO:
// - add support for progress functions
export class EdgeGenerator {
constructor() {
this.projectionDirection = new Vector3( 0, 1, 0 );
this.thresholdAngle = 50;
this.iterationTime = 30;
this.yOffset = 1e-6;
}
// Functions for generating the "hard" and silhouette edges of the geometry along the projection direction
getEdges( ...args ) {
const currIterationTime = this.iterationTime;
this.iterationTime = Infinity;
const result = this.getEdgesGenerator( ...args ).next().value;
this.iterationTime = currIterationTime;
return result;
}
async getEdgesAsync( ...args ) {
const task = this.getEdgesGenerator( ...args );
let res;
while ( ! res || ! res.done ) {
res = task.next();
await nextFrame();
}
return res.value;
}
*getEdgesGenerator( geometry, resultEdges = [] ) {
// handle arrays
if ( Array.isArray( geometry ) ) {
for ( let i = 0, l = geometry.length; i < l; i ++ ) {
yield* this.getEdgesGenerator( geometry[ i ], resultEdges );
}
return resultEdges;
}
const { projectionDirection, thresholdAngle, iterationTime, yOffset } = this;
if ( geometry.isObject3D ) {
const meshes = getAllMeshes( geometry );
let time = performance.now();
for ( let i = 0; i < meshes.length; i ++ ) {
if ( performance.now() - time > iterationTime ) {
yield;
}
const mesh = meshes[ i ];
const results = yield* generateEdges( mesh.geometry, [], {
matrix: mesh.matrixWorld,
thresholdAngle: thresholdAngle,
iterationTime: iterationTime,
} );
transformEdges( results, mesh.matrixWorld, yOffset );
// push the edges individually to avoid stack overflow
for ( let i = 0; i < results.length; i ++ ) {
results[ i ].mesh = mesh;
resultEdges.push( results[ i ] );
}
}
return resultEdges;
} else {
return yield* generateEdges( geometry, resultEdges, {
projectionDirection: projectionDirection,
thresholdAngle: thresholdAngle,
iterationTime: iterationTime,
} );
}
}
// Functions for generating a set of "intersection" edges within an existing geometry
// TODO: these needs to support generating "intersection edges" within a set of other geometries, as well
getIntersectionEdges( ...args ) {
const currIterationTime = this.iterationTime;
this.iterationTime = Infinity;
const result = this.getIntersectionEdgesGenerator( ...args ).next().value;
this.iterationTime = currIterationTime;
return result;
}
async getIntersectionEdgesAsync( ...args ) {
const task = this.getIntersectionEdgesGenerator( ...args );
let res;
while ( ! res || ! res.done ) {
res = task.next();
await nextFrame();
}
return res.value;
}
*getIntersectionEdgesGenerator( geometry, resultEdges = [] ) {
// handle arrays
if ( Array.isArray( geometry ) ) {
for ( let i = 0, l = geometry.length; i < l; i ++ ) {
yield* this.getIntersectionEdgesGenerator( geometry[ i ], resultEdges );
}
return resultEdges;
}
const { iterationTime, yOffset } = this;
if ( geometry.isObject3D ) {
// get the bounds trees from all geometry
const meshes = getAllMeshes( geometry );
const bvhs = new Map();
let time = performance.now();
for ( let i = 0; i < meshes.length; i ++ ) {
if ( performance.now() - time > iterationTime ) {
yield;
time = performance.now();
}
const mesh = meshes[ i ];
const geometry = mesh.geometry;
if ( ! bvhs.has( geometry ) ) {
const bvh = geometry.boundsTree || new MeshBVH( geometry, { maxLeafSize: 1 } );
bvhs.set( geometry, bvh );
}
}
// check each mesh against all others
time = performance.now();
for ( let i = 0; i < meshes.length; i ++ ) {
for ( let j = i; j < meshes.length; j ++ ) {
if ( performance.now() - time > iterationTime ) {
yield;
time = performance.now();
}
const meshA = meshes[ i ];
const meshB = meshes[ j ];
const bvhA = bvhs.get( meshA.geometry );
const bvhB = bvhs.get( meshB.geometry );
// A-1 * B * v
_BtoA
.copy( meshA.matrixWorld )
.invert()
.multiply( meshB.matrixWorld );
const results = generateIntersectionEdges( bvhA, bvhB, _BtoA, [], { iterationTime } );
transformEdges( results, meshA.matrixWorld, yOffset );
// push the edges individually to avoid stack overflow
for ( let i = 0; i < results.length; i ++ ) {
results[ i ].mesh = meshA;
resultEdges.push( results[ i ] );
}
}
}
return resultEdges;
} else {
let bvh;
if ( geometry.isBufferGeometry ) {
bvh = geometry.boundsTree || new MeshBVH( geometry, { maxLeafSize: 1 } );
} else {
bvh = geometry;
geometry = bvh.geometry;
}
_BtoA.identity();
return generateIntersectionEdges( bvh, bvh, _BtoA, resultEdges, { iterationTime } );
}
}
}
// add an offset to avoid precision errors when detecting intersections and clipping
function transformEdges( list, matrix, offset = 0 ) {
for ( let i = 0; i < list.length; i ++ ) {
const line = list[ i ];
line.applyMatrix4( matrix );
line.start.y += offset;
line.end.y += offset;
}
}
================================================
FILE: src/MeshVisibilityCuller.js
================================================
/** @import { WebGLRenderer, Object3D } from 'three' */
import {
ShaderMaterial,
GLSL3,
WebGLRenderTarget,
Box3,
Vector3,
Vector4,
OrthographicCamera,
Color,
Mesh,
NoBlending,
} from 'three';
import { getAllMeshes } from './utils/getAllMeshes.js';
// RGBA8 ID encoding - supports up to 16,777,215 objects (2^24 - 1)
// ID 0 is valid, background is indicated by alpha = 0
function encodeId( id, target ) {
target.x = ( id & 0xFF ) / 255;
target.y = ( ( id >> 8 ) & 0xFF ) / 255;
target.z = ( ( id >> 16 ) & 0xFF ) / 255;
target.w = 1;
}
function decodeId( buffer, index ) {
return buffer[ index ] | ( buffer[ index + 1 ] << 8 ) | ( buffer[ index + 2 ] << 16 );
}
// TODO: WebGPU or occlusion queries would let us accelerate this. Ideally would we "contract" the depth buffer by one pixel by
// taking the lowest value from all surrounding pixels in order to avoid mesh misses.
/**
* Utility for determining visible geometry from a top down orthographic perspective. This can
* be run before performing projection generation to reduce the complexity of the operation at
* the cost of potentially missing small details.
*
* Constructor for the visibility culler that takes the renderer to use for culling.
* @param {WebGLRenderer} renderer
* @param {Object} [options]
* @param {number} [options.pixelsPerMeter=0.1]
*/
export class MeshVisibilityCuller {
constructor( renderer, options = {} ) {
const { pixelsPerMeter = 0.1 } = options;
/**
* The size of a pixel on a single dimension. If this results in a texture larger than what
* the graphics context can provide then the rendering is tiled.
* @type {number}
*/
this.pixelsPerMeter = pixelsPerMeter;
this.renderer = renderer;
}
/**
* Returns the set of meshes that are visible within the given object.
* @param {Object3D|Array<Object3D>} object
* @returns {Promise<Array<Object3D>>}
*/
async cull( objects ) {
objects = getAllMeshes( objects );
const { renderer, pixelsPerMeter } = this;
const size = new Vector3();
const camera = new OrthographicCamera();
const box = new Box3();
const idMesh = new Mesh( undefined, new IDMaterial() );
idMesh.matrixAutoUpdate = false;
idMesh.matrixWorldAutoUpdate = false;
const target = new WebGLRenderTarget( 1, 1 );
// get the bounds of the image
box.makeEmpty();
objects.forEach( o => {
box.expandByObject( o );
} );
// get the bounds dimensions
box.getSize( size );
// calculate the tile and target size
const maxTextureSize = Math.min( renderer.capabilities.maxTextureSize, 2 ** 13 );
const pixelWidth = Math.ceil( size.x / pixelsPerMeter );
const pixelHeight = Math.ceil( size.z / pixelsPerMeter );
const tilesX = Math.ceil( pixelWidth / maxTextureSize );
const tilesY = Math.ceil( pixelHeight / maxTextureSize );
target.setSize( Math.ceil( pixelWidth / tilesX ), Math.ceil( pixelHeight / tilesY ) );
// set the camera bounds
camera.rotation.x = - Math.PI / 2;
camera.far = ( box.max.y - box.min.y ) + camera.near;
camera.position.y = box.max.y + camera.near;
// save render state
const color = renderer.getClearColor( new Color() );
const alpha = renderer.getClearAlpha();
const renderTarget = renderer.getRenderTarget();
const autoClear = renderer.autoClear;
// render ids
const readBuffer = new Uint8Array( target.width * target.height * 4 );
const visibleSet = new Set();
const stepX = size.x / tilesX;
const stepZ = size.z / tilesY;
for ( let x = 0; x < tilesX; x ++ ) {
for ( let y = 0; y < tilesY; y ++ ) {
// update camera
camera.left = box.min.x + stepX * x;
camera.top = - ( box.min.z + stepZ * y );
camera.right = camera.left + stepX;
camera.bottom = camera.top - stepZ;
camera.updateProjectionMatrix();
// clear the camera
renderer.autoClear = false;
renderer.setClearColor( 0, 0 );
renderer.setRenderTarget( target );
renderer.clear();
for ( let i = 0; i < objects.length; i ++ ) {
const object = objects[ i ];
idMesh.matrixWorld.copy( object.matrixWorld );
idMesh.geometry = object.geometry;
idMesh.material.objectId = i;
renderer.render( idMesh, camera );
}
// reset render state before async operation to avoid corruption
renderer.setClearColor( color, alpha );
renderer.setRenderTarget( renderTarget );
renderer.autoClear = autoClear;
const buffer = await renderer.readRenderTargetPixelsAsync( target, 0, 0, target.width, target.height, readBuffer );
// find all visible objects - decode RGBA to ID
for ( let i = 0, l = buffer.length; i < l; i += 4 ) {
// alpha = 0 indicates background (no object)
if ( buffer[ i + 3 ] === 0 ) continue;
const id = decodeId( buffer, i );
visibleSet.add( objects[ id ] );
}
}
}
// dispose of intermediate values
idMesh.material.dispose();
target.dispose();
return Array.from( visibleSet );
}
}
class IDMaterial extends ShaderMaterial {
set objectId( v ) {
encodeId( v, this.uniforms.objectId.value );
}
constructor( params ) {
super( {
glslVersion: GLSL3,
blending: NoBlending,
uniforms: {
objectId: { value: new Vector4() },
},
vertexShader: /* glsl */`
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
fragmentShader: /* glsl */`
layout(location = 0) out vec4 out_id;
uniform vec4 objectId;
void main() {
out_id = objectId;
}
`,
} );
this.setValues( params );
}
}
================================================
FILE: src/PlanarIntersectionGenerator.js
================================================
import { BufferAttribute, BufferGeometry, Line3, Plane, Vector3 } from 'three';
import { MeshBVH } from 'three-mesh-bvh';
const _line = new Line3();
const _target = new Line3();
const _vec = new Vector3();
const EPS = 1e-16;
/**
* Utility for generating the line segments produced by a planar intersection with geometry.
*/
export class PlanarIntersectionGenerator {
constructor() {
/**
* Plane that defaults to y up plane at the origin.
* @type {Plane}
*/
this.plane = new Plane( new Vector3( 0, 1, 0 ), 0 );
}
/**
* Generates a geometry of the resulting line segments from the planar intersection.
* @param {MeshBVH|BufferGeometry} geometry
* @returns {BufferGeometry}
*/
generate( bvh ) {
const { plane } = this;
if ( bvh instanceof BufferGeometry ) {
bvh = new MeshBVH( bvh, { maxLeafSize: 1 } );
}
const edgesArray = [];
bvh.shapecast( {
intersectsBounds: box => {
return plane.intersectsBox( box );
},
intersectsTriangle: tri => {
const { points } = tri;
let foundPoints = 0;
for ( let i = 0; i < 3; i ++ ) {
const ni = ( i + 1 ) % 3;
_line.start.copy( points[ i ] );
_line.end.copy( points[ ni ] );
if ( plane.intersectLine( _line, _vec ) ) {
if ( foundPoints === 1 ) {
if ( _vec.distanceTo( _target.start ) > EPS ) {
_target.end.copy( _vec );
foundPoints ++;
break;
}
} else {
_target.start.copy( _vec );
foundPoints ++;
}
}
}
if ( foundPoints === 2 ) {
edgesArray.push( ..._target.start, ..._target.end );
}
},
} );
// generate and return line geometry
const edgeGeom = new BufferGeometry();
const edgeBuffer = new BufferAttribute( new Float32Array( edgesArray ), 3, true );
edgeGeom.setAttribute( 'position', edgeBuffer );
return edgeGeom;
}
}
================================================
FILE: src/ProjectionGenerator.js
================================================
/** @import { Object3D } from 'three' */
import {
BufferGeometry,
Vector3,
BufferAttribute,
Mesh,
} from 'three';
import { MeshBVH, SAH } from 'three-mesh-bvh';
import { isYProjectedLineDegenerate } from './utils/triangleLineUtils.js';
import { overlapsToLines } from './utils/overlapUtils.js';
import { EdgeGenerator } from './EdgeGenerator.js';
import { LineObjectsBVH } from './utils/LineObjectsBVH.js';
import { bvhcastEdges } from './utils/bvhcastEdges.js';
import { getAllMeshes } from './utils/getAllMeshes.js';
import { nextFrame } from './utils/nextFrame.js';
const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
function toLineGeometry( edges, ranges = null ) {
// if no ranges provided, treat the whole array as one range
const activeRanges = ranges ?? [ { start: 0, count: edges.length } ];
let totalCount = 0;
for ( let i = 0; i < activeRanges.length; i ++ ) {
totalCount += activeRanges[ i ].count;
}
const edgeArray = new Float32Array( totalCount * 6 );
let c = 0;
for ( let r = 0; r < activeRanges.length; r ++ ) {
const { start, count } = activeRanges[ r ];
for ( let i = start, l = start + count; i < l; i ++ ) {
const line = edges[ i ];
edgeArray[ c ++ ] = line[ 0 ];
edgeArray[ c ++ ] = 0;
edgeArray[ c ++ ] = line[ 2 ];
edgeArray[ c ++ ] = line[ 3 ];
edgeArray[ c ++ ] = 0;
edgeArray[ c ++ ] = line[ 5 ];
}
}
const edgeGeom = new BufferGeometry();
const edgeBuffer = new BufferAttribute( edgeArray, 3, false );
edgeGeom.setAttribute( 'position', edgeBuffer );
return edgeGeom;
}
/**
* Set of projected edges produced by ProjectionGenerator.
*/
export class EdgeSet {
constructor() {
this.meshToSegments = new Map();
this._rangeCache = null;
}
/**
* Returns a new BufferGeometry representing the edges.
*
* Pass a list of meshes in to extract edges from a specific subset of meshes in the given
* order. Returns all edges if null.
* @param {Array<Mesh>|null} [meshes=null]
* @returns {BufferGeometry}
*/
getLineGeometry( meshes = null ) {
const activeMeshes = meshes !== null ? meshes : Array.from( this.meshToSegments.keys() );
const segments = [];
for ( let i = 0; i < activeMeshes.length; i ++ ) {
const segs = this.meshToSegments.get( activeMeshes[ i ] );
if ( segs ) {
for ( let j = 0; j < segs.length; j ++ ) segments.push( segs[ j ] );
}
}
return toLineGeometry( segments );
}
/**
* Returns the range of vertices associated with the given mesh in the geometry returned from
* getLineGeometry. The `start` value is only relevant if lines are generated with the default
* order and set of meshes.
*
* Can be used to add extra vertex attributes in a geometry associated with a specific subrange
* of the geometry.
* @param {Mesh} mesh
* @returns {{ start: number, count: number }|null}
*/
getRangeForMesh( mesh ) {
if ( ! this._rangeCache ) {
this._rangeCache = new Map();
let start = 0;
for ( const [ m, segs ] of this.meshToSegments ) {
this._rangeCache.set( m, { start: start * 2, count: segs.length * 2 } );
start += segs.length;
}
}
return this._rangeCache.get( mesh ) ?? null;
}
}
/**
* Result object returned by ProjectionGenerator containing visible and hidden edge sets.
*/
export class ProjectionResult {
constructor() {
/** @type {EdgeSet} */
this.visibleEdges = new EdgeSet();
/** @type {EdgeSet} */
this.hiddenEdges = new EdgeSet();
}
}
class ProjectedEdgeCollector {
constructor( scene ) {
this.meshes = getAllMeshes( scene );
this.bvhs = new Map();
this.result = new ProjectionResult();
this.iterationTime = 30;
}
addEdges( ...args ) {
const currIterationTime = this.iterationTime;
this.iterationTime = Infinity;
const result = this.addEdgesGenerator( ...args ).next().value;
this.iterationTime = currIterationTime;
return result;
}
// all edges are expected to be in world coordinates
*addEdgesGenerator( edges, options = {} ) {
const { meshes, bvhs, iterationTime } = this;
let time = performance.now();
for ( let i = 0; i < meshes.length; i ++ ) {
if ( performance.now() - time > iterationTime ) {
yield;
time = performance.now();
}
const mesh = meshes[ i ];
const geometry = mesh.geometry;
if ( ! bvhs.has( geometry ) ) {
const bvh = geometry.boundsTree || new MeshBVH( geometry );
bvhs.set( geometry, bvh );
}
}
// initialize hidden line object
const hiddenOverlapMap = {};
for ( let i = 0; i < edges.length; i ++ ) {
hiddenOverlapMap[ i ] = [];
}
// construct bvh
const edgesBvh = new LineObjectsBVH( edges, { maxLeafSize: 2, strategy: SAH } );
time = performance.now();
for ( let m = 0; m < meshes.length; m ++ ) {
if ( performance.now() - time > iterationTime ) {
if ( options.onProgress ) {
options.onProgress( m, meshes.length );
}
yield;
time = performance.now();
}
// use bvhcast to compare all edges against all meshes
const mesh = meshes[ m ];
bvhcastEdges( edgesBvh, bvhs.get( mesh.geometry ), mesh, hiddenOverlapMap );
}
// construct the projections
const { result } = this;
for ( let i = 0; i < edges.length; i ++ ) {
if ( performance.now() - time > iterationTime ) {
yield;
time = performance.now();
}
// convert the overlap points to proper lines
const line = edges[ i ];
const mesh = line.mesh;
const hiddenOverlaps = hiddenOverlapMap[ i ];
if ( ! result.visibleEdges.meshToSegments.has( mesh ) ) {
result.visibleEdges.meshToSegments.set( mesh, [] );
result.hiddenEdges.meshToSegments.set( mesh, [] );
}
overlapsToLines( line, hiddenOverlaps, false, result.visibleEdges.meshToSegments.get( mesh ) );
overlapsToLines( line, hiddenOverlaps, true, result.hiddenEdges.meshToSegments.get( mesh ) );
}
}
}
/**
* @callback ProjectionProgressCallback
* @param {number} percent
* @param {string} message
*/
/**
* Utility for generating 2D projections of 3D geometry.
*/
export class ProjectionGenerator {
constructor() {
/**
* How long to spend trimming edges before yielding.
* @type {number}
*/
this.iterationTime = 30;
/**
* The threshold angle in degrees at which edges are generated.
* @type {number}
*/
this.angleThreshold = 50;
/**
* Whether to generate edges representing the intersections between triangles.
* @type {boolean}
*/
this.includeIntersectionEdges = true;
}
/**
* Generate the geometry with a promise-style API.
* @async
* @param {Object3D|BufferGeometry|Array<Object3D>} geometry
* @param {Object} [options]
* @param {ProjectionProgressCallback} [options.onProgress]
* @param {AbortSignal} [options.signal]
* @returns {ProjectionResult}
*/
async generateAsync( geometry, options = {} ) {
const { signal } = options;
const task = this.generate( geometry, options );
let res;
while ( ! res || ! res.done ) {
res = task.next();
await nextFrame();
signal.throwIfAborted();
}
return res.value;
}
/**
* Generate the edge geometry result using a generator function.
* @param {Object3D|BufferGeometry|Array<Object3D>} scene
* @param {Object} [options]
* @param {ProjectionProgressCallback} [options.onProgress]
* @yields {void}
* @returns {ProjectionResult}
*/
*generate( scene, options = {} ) {
const { iterationTime, angleThreshold, includeIntersectionEdges } = this;
const { onProgress = () => {} } = options;
if ( scene.isBufferGeometry ) {
scene = new Mesh( scene );
}
const edgeGenerator = new EdgeGenerator();
edgeGenerator.iterationTime = iterationTime;
edgeGenerator.thresholdAngle = angleThreshold;
edgeGenerator.projectionDirection.copy( UP_VECTOR );
onProgress( 0, 'Extracting edges' );
let edges = [];
yield* edgeGenerator.getEdgesGenerator( scene, edges );
if ( includeIntersectionEdges ) {
onProgress( 0, 'Extracting self-intersecting edges' );
yield* edgeGenerator.getIntersectionEdgesGenerator( scene, edges );
}
// filter out any degenerate projected edges
onProgress( 0, 'Filtering edges' );
edges = edges.filter( e => ! isYProjectedLineDegenerate( e ) );
edges.sort( ( a, b ) => {
const uuidA = a.mesh.uuid;
const uuidB = b.mesh.uuid;
if ( uuidA === uuidB ) {
return 0;
} else {
return uuidA < uuidB ? - 1 : 1;
}
} );
yield;
const collector = new ProjectedEdgeCollector( scene );
collector.iterationTime = iterationTime;
onProgress( 0, 'Clipping edges' );
yield* collector.addEdgesGenerator( edges, {
onProgress: ! onProgress ? null : ( prog, tot ) => {
onProgress( prog / tot, 'Clipping edges', collector.result );
},
} );
return collector.result;
}
}
================================================
FILE: src/SilhouetteGenerator.js
================================================
import { Path64, Clipper, FillRule } from 'clipper2-js';
import { ShapeGeometry, Vector3, Shape, Vector2, Triangle, ShapeUtils, BufferGeometry } from 'three';
import { compressPoints } from './utils/compressPoints.js';
import { triangleIsInsidePaths } from './utils/triangleIsInsidePaths.js';
import { getSizeSortedTriList } from './utils/getSizeSortedTriList.js';
import { getTriCount } from './utils/geometryUtils.js';
const AREA_EPSILON = 1e-8;
const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
const _tri = /* @__PURE__ */ new Triangle();
const _normal = /* @__PURE__ */ new Vector3();
const _center = /* @__PURE__ */ new Vector3();
const _vec = /* @__PURE__ */ new Vector3();
function convertPathToGeometry( path, scale ) {
const vector2s = path.map( points => {
return points.flatMap( v => new Vector2( v.x / scale, v.y / scale ) );
} );
const holesShapes = vector2s
.filter( p => ShapeUtils.isClockWise( p ) )
.map( p => new Shape( p ) );
const solidShapes = vector2s
.filter( p => ! ShapeUtils.isClockWise( p ) )
.map( p => {
const shape = new Shape( p );
shape.holes = holesShapes;
return shape;
} );
// flip the triangles so they're facing in the right direction
const result = new ShapeGeometry( solidShapes ).rotateX( Math.PI / 2 );
result.index.array.reverse();
return result;
}
function convertPathToLineSegments( path, scale ) {
const arr = [];
path.forEach( points => {
for ( let i = 0, l = points.length; i < l; i ++ ) {
const i1 = ( i + 1 ) % points.length;
const p0 = points[ i ];
const p1 = points[ i1 ];
arr.push(
new Vector3( p0.x / scale, 0, p0.y / scale ),
new Vector3( p1.x / scale, 0, p1.y / scale )
);
}
} );
const result = new BufferGeometry();
result.setFromPoints( arr );
return result;
}
/** @type {number} */
export const OUTPUT_MESH = 0;
/** @type {number} */
export const OUTPUT_LINE_SEGMENTS = 1;
/** @type {number} */
export const OUTPUT_BOTH = 2;
/**
* @callback SilhouetteProgressCallback
* @param {number} percent
*/
/**
* Used for generating a projected silhouette of a geometry using the clipper2-js project. Performing
* these operations can be extremely slow with more complex geometry and not always yield a stable result.
*/
export class SilhouetteGenerator {
constructor() {
/**
* How long to spend trimming edges before yielding.
* @type {number}
*/
this.iterationTime = 30;
this.intScalar = 1e9;
/**
* If `false` then only the triangles facing upwards are included in the silhouette.
* @type {boolean}
*/
this.doubleSided = false;
/**
* Whether to sort triangles and project them large-to-small. In some cases this can cause
* the performance to drop since the union operation is best performed with smooth, simple
* edge shapes.
* @type {boolean}
*/
this.sortTriangles = false;
/**
* Whether to output mesh geometry, line segments geometry, or both in an array
* ( `[ mesh, line segments ]` ).
* @type {number}
*/
this.output = OUTPUT_MESH;
}
/**
* Generate the silhouette geometry with a promise-style API.
* @async
* @param {BufferGeometry} geometry
* @param {Object} [options]
* @param {SilhouetteProgressCallback} [options.onProgress]
* @param {AbortSignal} [options.signal]
* @returns {BufferGeometry|Array<BufferGeometry>}
*/
generateAsync( geometry, options = {} ) {
return new Promise( ( resolve, reject ) => {
const { signal } = options;
const task = this.generate( geometry, options );
run();
function run() {
if ( signal && signal.aborted ) {
reject( new Error( 'SilhouetteGenerator: Process aborted via AbortSignal.' ) );
return;
}
const result = task.next();
if ( result.done ) {
resolve( result.value );
} else {
requestAnimationFrame( run );
}
}
} );
}
/**
* Generate the geometry using a generator function.
* @param {BufferGeometry} geometry
* @param {Object} [options]
* @param {SilhouetteProgressCallback} [options.onProgress]
* @yields {void}
* @returns {BufferGeometry|Array<BufferGeometry>}
*/
*generate( geometry, options = {} ) {
const { iterationTime, intScalar, doubleSided, output, sortTriangles } = this;
const { onProgress } = options;
const power = Math.log10( intScalar );
const extendMultiplier = Math.pow( 10, - ( power - 2 ) );
const index = geometry.index;
const posAttr = geometry.attributes.position;
const triCount = getTriCount( geometry );
let overallPath = null;
const triList = sortTriangles ?
getSizeSortedTriList( geometry ) :
new Array( triCount ).fill().map( ( v, i ) => i );
const handle = {
getGeometry() {
if ( output === OUTPUT_MESH ) {
return convertPathToGeometry( overallPath, intScalar );
} else if ( output === OUTPUT_LINE_SEGMENTS ) {
return convertPathToLineSegments( overallPath, intScalar );
} else {
return [
convertPathToGeometry( overallPath, intScalar ),
convertPathToLineSegments( overallPath, intScalar ),
];
}
}
};
let time = performance.now();
for ( let ti = 0; ti < triCount; ti ++ ) {
const i = triList[ ti ] * 3;
let i0 = i + 0;
let i1 = i + 1;
let i2 = i + 2;
if ( index ) {
i0 = index.getX( i0 );
i1 = index.getX( i1 );
i2 = index.getX( i2 );
}
// get the triangle
const { a, b, c } = _tri;
a.fromBufferAttribute( posAttr, i0 );
b.fromBufferAttribute( posAttr, i1 );
c.fromBufferAttribute( posAttr, i2 );
if ( ! doubleSided ) {
_tri.getNormal( _normal );
if ( _normal.dot( UP_VECTOR ) < 0 ) {
continue;
}
}
// flatten the triangle
a.y = 0;
b.y = 0;
c.y = 0;
if ( _tri.getArea() < AREA_EPSILON ) {
continue;
}
// expand the triangle by a small degree to ensure overlap
_center
.copy( a )
.add( b )
.add( c )
.multiplyScalar( 1 / 3 );
_vec.subVectors( a, _center ).normalize();
a.addScaledVector( _vec, extendMultiplier );
_vec.subVectors( b, _center ).normalize();
b.addScaledVector( _vec, extendMultiplier );
_vec.subVectors( c, _center ).normalize();
c.addScaledVector( _vec, extendMultiplier );
// create the path
const path = new Path64();
path.push( Clipper.makePath( [
a.x * intScalar, a.z * intScalar,
b.x * intScalar, b.z * intScalar,
c.x * intScalar, c.z * intScalar,
] ) );
a.multiplyScalar( intScalar );
b.multiplyScalar( intScalar );
c.multiplyScalar( intScalar );
if ( overallPath && triangleIsInsidePaths( _tri, overallPath ) ) {
continue;
}
// perform union
if ( overallPath === null ) {
overallPath = path;
} else {
overallPath = Clipper.Union( overallPath, path, FillRule.NonZero );
overallPath.forEach( path => compressPoints( path ) );
}
const delta = performance.now() - time;
if ( delta > iterationTime ) {
if ( onProgress ) {
const progress = ti / triCount;
onProgress( progress, handle );
}
yield;
time = performance.now();
}
}
return handle.getGeometry();
}
}
================================================
FILE: src/index.js
================================================
export * from './MeshVisibilityCuller.js';
export * from './ProjectionGenerator.js';
export * from './SilhouetteGenerator.js';
export * from './PlanarIntersectionGenerator.js';
================================================
FILE: src/utils/LineObjectsBVH.js
================================================
import { BVH } from 'three-mesh-bvh';
export class LineObjectsBVH extends BVH {
get lines() {
return this.primitiveBuffer;
}
constructor( lines, options ) {
super( options );
this.primitiveBuffer = lines;
this.primitiveBufferStride = 1;
this.heightOffset = options.heightOffset ?? 1e3;
this.init( options );
}
writePrimitiveBounds( i, targetBuffer, writeOffset ) {
const { primitiveBuffer, heightOffset } = this;
const { start, end } = primitiveBuffer[ i ];
targetBuffer[ writeOffset + 0 ] = Math.min( start.x, end.x );
targetBuffer[ writeOffset + 1 ] = Math.min( start.y, end.y );
targetBuffer[ writeOffset + 2 ] = Math.min( start.z, end.z );
targetBuffer[ writeOffset + 3 ] = Math.max( start.x, end.x );
targetBuffer[ writeOffset + 4 ] = Math.max( start.y, end.y ) + heightOffset;
targetBuffer[ writeOffset + 5 ] = Math.max( start.z, end.z );
}
getRootRanges() {
return [ { offset: 0, count: this.primitiveBuffer.length } ];
}
}
================================================
FILE: src/utils/ProjectionEdge.js
================================================
import { Line3 } from 'three';
export class ProjectionEdge extends Line3 {
constructor( start, end ) {
super( start, end );
this.mesh = null;
}
copy( source ) {
super.copy( source );
this.mesh = source.mesh || null;
return this;
}
}
================================================
FILE: src/utils/bvhcastEdges.js
================================================
import { isLineTriangleEdge } from './triangleLineUtils.js';
import { trimToBeneathTriPlane } from './trimToBeneathTriPlane.js';
import { getProjectedLineOverlap } from './getProjectedLineOverlap.js';
import { appendOverlapRange } from './getProjectedOverlaps.js';
import { BackSide, DoubleSide, Line3, Vector3 } from 'three';
import { ExtendedTriangle } from 'three-mesh-bvh';
const UP_VECTOR = new Vector3( 0, 1, 0 );
const DIST_THRESHOLD = 1e-10;
const _beneathLine = /* @__PURE__ */ new Line3();
const _overlapLine = /* @__PURE__ */ new Line3();
const _tri = /* @__PURE__ */ new ExtendedTriangle();
_tri.update = () => {
// override the "update" function so we only calculate the piece we need
_tri.plane.setFromCoplanarPoints( ..._tri.points );
};
export function bvhcastEdges( edgesBvh, bvh, mesh, hiddenOverlapMap ) {
const { geometry, matrixWorld, material } = mesh;
const side = material.side;
const inverted = matrixWorld.determinant() < 0;
const edges = edgesBvh.lines;
edgesBvh.bvhcast( bvh, matrixWorld, {
intersectsRanges: ( edgeOffset, edgeCount, meshOffset, meshCount ) => {
for ( let i = meshOffset, l = meshCount + meshOffset; i < l; i ++ ) {
let i0 = 3 * i + 0;
let i1 = 3 * i + 1;
let i2 = 3 * i + 2;
if ( geometry.index ) {
i0 = geometry.index.getX( i0 );
i1 = geometry.index.getX( i1 );
i2 = geometry.index.getX( i2 );
}
// Transform mesh triangle to world space
const { a, b, c } = _tri;
a.fromBufferAttribute( geometry.attributes.position, i0 ).applyMatrix4( matrixWorld );
b.fromBufferAttribute( geometry.attributes.position, i1 ).applyMatrix4( matrixWorld );
c.fromBufferAttribute( geometry.attributes.position, i2 ).applyMatrix4( matrixWorld );
_tri.needsUpdate = true;
_tri.update();
// back face culling
if ( side !== DoubleSide ) {
const faceUp = _tri.plane.normal.dot( UP_VECTOR ) !== inverted;
if ( faceUp === ( side === BackSide ) ) {
continue;
}
}
const triMaxY = Math.max( a.y, b.y, c.y );
const triMinY = Math.min( a.y, b.y, c.y );
for ( let e = edgeOffset, le = edgeCount + edgeOffset; e < le; e ++ ) {
const _line = edges[ e ];
// Calculate edge and triangle bounds
const lineMinY = Math.min( _line.start.y, _line.end.y );
const lineMaxY = Math.max( _line.start.y, _line.end.y );
// Skip if triangle is completely below the line
if ( triMaxY <= lineMinY ) {
continue;
}
// Skip if this line lies on a triangle edge
if ( isLineTriangleEdge( _tri, _line ) ) {
continue;
}
// Retrieve the portion of line that is below the triangle plane
if ( lineMaxY < triMinY ) {
_beneathLine.copy( _line );
} else if ( ! trimToBeneathTriPlane( _tri, _line, _beneathLine ) ) {
continue;
}
// Cull overly small edges
if ( _beneathLine.distance() < DIST_THRESHOLD ) {
continue;
}
// Calculate projected overlap and store in hiddenOverlapMap
if ( getProjectedLineOverlap( _beneathLine, _tri, _overlapLine ) ) {
appendOverlapRange( _line, _overlapLine, hiddenOverlapMap[ e ] );
}
}
}
},
} );
}
================================================
FILE: src/utils/compressPoints.js
================================================
const DIRECTION_EPSILON = 1e-3;
const DIST_EPSILON = 1e2;
function sameDirection( p0, p1, p2 ) {
const dx1 = p1.x - p0.x;
const dy1 = p1.y - p0.y;
const dx2 = p2.x - p1.x;
const dy2 = p2.y - p1.y;
const s1 = dx1 / dy1;
const s2 = dx2 / dy2;
return Math.abs( s1 - s2 ) < DIRECTION_EPSILON;
}
function areClose( p0, p1 ) {
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
return Math.sqrt( dx * dx + dy * dy ) < DIST_EPSILON;
}
function areEqual( p0, p1 ) {
return p0.x === p1.x && p0.y === p1.y;
}
export function compressPoints( points ) {
for ( let k = 0; k < points.length; k ++ ) {
// remove points that are equal or very close to each other
const v = points[ k ];
while ( true ) {
const k1 = k + 1;
if (
points.length > k1 &&
(
areEqual( v, points[ k1 ] ) ||
areClose( v, points[ k1 ] )
)
) {
points.splice( k1, 1 );
} else {
break;
}
}
// join lines that are almost the same direction
while ( true ) {
const k1 = k + 1;
const k2 = k + 2;
if (
points.length > k2 &&
sameDirection( v, points[ k1 ], points[ k2 ] )
) {
points.splice( k + 1, 1 );
} else {
break;
}
}
}
}
================================================
FILE: src/utils/generateEdges.js
================================================
import { Vector3, Triangle, MathUtils, Matrix4 } from 'three';
import { ProjectionEdge } from './ProjectionEdge.js';
// Modified version of js EdgesGeometry logic to handle silhouette edges
const EPSILON = 1e-10;
const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
const _v0 = /* @__PURE__ */ new Vector3();
const _v1 = /* @__PURE__ */ new Vector3();
const _normal = /* @__PURE__ */ new Vector3();
const _triangle = /* @__PURE__ */ new Triangle();
const _triangleLocal = /* @__PURE__ */ new Triangle();
const _localProjection = /* @__PURE__ */ new Vector3();
const _invMat = /* @__PURE__ */ new Matrix4();
export function* generateEdges( geometry, target = [], options = {} ) {
const {
matrix = null,
thresholdAngle = 1,
iterationTime = 30,
} = options;
_localProjection.copy( UP_VECTOR );
let isAffine = true;
if ( matrix ) {
isAffine =
matrix.elements[ 3 ] === 0 &&
matrix.elements[ 7 ] === 0 &&
matrix.elements[ 11 ] === 0 &&
matrix.elements[ 15 ] === 1;
if ( isAffine ) {
_invMat.copy( matrix ).invert();
_localProjection.transformDirection( _invMat );
}
}
const precisionPoints = 4;
const precision = Math.pow( 10, precisionPoints );
const thresholdDot = Math.cos( MathUtils.DEG2RAD * thresholdAngle );
const indexAttr = geometry.getIndex();
const positionAttr = geometry.getAttribute( 'position' );
const indexCount = indexAttr ? indexAttr.count : positionAttr.count;
const indexArr = [ 0, 0, 0 ];
const vertKeys = [ 'a', 'b', 'c' ];
const hashes = new Array( 3 );
const edgeData = {};
let time = performance.now();
for ( let i = 0; i < indexCount; i += 3 ) {
if ( performance.now() - time > iterationTime ) {
yield;
time = performance.now();
}
if ( indexAttr ) {
indexArr[ 0 ] = indexAttr.getX( i );
indexArr[ 1 ] = indexAttr.getX( i + 1 );
indexArr[ 2 ] = indexAttr.getX( i + 2 );
} else {
indexArr[ 0 ] = i;
indexArr[ 1 ] = i + 1;
indexArr[ 2 ] = i + 2;
}
const { a, b, c } = _triangleLocal;
_triangleLocal.a.fromBufferAttribute( positionAttr, indexArr[ 0 ] );
_triangleLocal.b.fromBufferAttribute( positionAttr, indexArr[ 1 ] );
_triangleLocal.c.fromBufferAttribute( positionAttr, indexArr[ 2 ] );
// create hashes for the edge from the vertices
hashes[ 0 ] = `${ Math.round( a.x * precision ) },${ Math.round( a.y * precision ) },${ Math.round( a.z * precision ) }`;
hashes[ 1 ] = `${ Math.round( b.x * precision ) },${ Math.round( b.y * precision ) },${ Math.round( b.z * precision ) }`;
hashes[ 2 ] = `${ Math.round( c.x * precision ) },${ Math.round( c.y * precision ) },${ Math.round( c.z * precision ) }`;
// skip degenerate triangles
if ( hashes[ 0 ] === hashes[ 1 ] || hashes[ 1 ] === hashes[ 2 ] || hashes[ 2 ] === hashes[ 0 ] ) {
continue;
}
// compute normal — fast path uses local-space normal with pre-transformed
// projection direction; slow path transforms vertices for world-space normal
if ( matrix && ! isAffine ) {
_triangle.copy( _triangleLocal );
_triangle.a.applyMatrix4( matrix );
_triangle.b.applyMatrix4( matrix );
_triangle.c.applyMatrix4( matrix );
_triangle.getNormal( _normal );
} else {
_triangleLocal.getNormal( _normal );
}
// iterate over every edge
for ( let j = 0; j < 3; j ++ ) {
// get the first and next vertex making up the edge
const jNext = ( j + 1 ) % 3;
const vecHash0 = hashes[ j ];
const vecHash1 = hashes[ jNext ];
const v0 = _triangleLocal[ vertKeys[ j ] ];
const v1 = _triangleLocal[ vertKeys[ jNext ] ];
const hash = `${ vecHash0 }_${ vecHash1 }`;
const reverseHash = `${ vecHash1 }_${ vecHash0 }`;
if ( reverseHash in edgeData && edgeData[ reverseHash ] ) {
// if we found a sibling edge add it into the vertex array if
// it meets the angle threshold and delete the edge from the map.
const otherNormal = edgeData[ reverseHash ].normal;
const meetsThreshold = _normal.dot( otherNormal ) <= thresholdDot;
// get the dot product relative to the projection angle and
// add an epsilon for nearly vertical triangles
const _projDir = _localProjection;
let normDot = _projDir.dot( _normal );
normDot = Math.abs( normDot ) < EPSILON ? 0 : normDot;
let otherDot = _projDir.dot( otherNormal );
otherDot = Math.abs( otherDot ) < EPSILON ? 0 : otherDot;
const projectionThreshold = Math.sign( normDot ) !== Math.sign( otherDot );
if ( meetsThreshold || projectionThreshold ) {
const line = new ProjectionEdge();
line.start.copy( v0 );
line.end.copy( v1 );
target.push( line );
}
edgeData[ reverseHash ] = null;
} else if ( ! ( hash in edgeData ) ) {
// if we've already got an edge here then skip adding a new one
edgeData[ hash ] = {
index0: indexArr[ j ],
index1: indexArr[ jNext ],
normal: _normal.clone(),
};
}
}
}
// iterate over all remaining, unmatched edges and add them to the vertex array
for ( const key in edgeData ) {
if ( edgeData[ key ] ) {
const { index0, index1 } = edgeData[ key ];
_v0.fromBufferAttribute( positionAttr, index0 );
_v1.fromBufferAttribute( positionAttr, index1 );
const line = new ProjectionEdge();
line.start.copy( _v0 );
line.end.copy( _v1 );
target.push( line );
}
}
return target;
}
================================================
FILE: src/utils/generateIntersectionEdges.js
================================================
import { Line3 } from 'three';
import { isLineTriangleEdge } from './triangleLineUtils.js';
import { ProjectionEdge } from './ProjectionEdge.js';
// TODO: How can we add support for "iterationTime"?
const _line = /* @__PURE__ */ new Line3();
export function generateIntersectionEdges( bvhA, bvhB, matrixBToA, target = [] ) {
bvhA.bvhcast( bvhB, matrixBToA, {
intersectsTriangles: ( tri1, tri2 ) => {
if ( areTrianglesOnEdge( tri1, tri2 ) ) {
return false;
}
if ( tri1.needsUpdate ) {
tri1.update();
}
if ( tri2.needsUpdate ) {
tri2.update();
}
if ( Math.abs( tri1.plane.normal.dot( tri2.plane.normal ) ) > 1 - 1e-6 ) {
return false;
}
if (
tri1.intersectsTriangle( tri2, _line, true ) &&
! isLineTriangleEdge( tri1, _line ) &&
! isLineTriangleEdge( tri2, _line )
) {
target.push( new ProjectionEdge().copy( _line ) );
}
}
} );
return target;
}
function areVectorsEqual( a, b ) {
return a.distanceTo( b ) < 1e-10;
}
function areTrianglesOnEdge( t1, t2 ) {
const indices = [ 'a', 'b', 'c' ];
let tot = 0;
for ( let i = 0; i < 3; i ++ ) {
for ( let j = 0; j < 3; j ++ ) {
const v0 = t1[ indices[ i ] ];
const v1 = t2[ indices[ j ] ];
if ( areVectorsEqual( v0, v1 ) ) {
tot ++;
}
}
}
return tot >= 2;
}
================================================
FILE: src/utils/geometryUtils.js
================================================
export function getTriCount( geometry ) {
const { index } = geometry;
const posAttr = geometry.attributes.position;
return index ? index.count / 3 : posAttr.count / 3;
}
================================================
FILE: src/utils/getAllMeshes.js
================================================
export function getAllMeshes( scene ) {
let arr;
if ( Array.isArray( scene ) ) {
arr = scene;
} else {
arr = [ scene ];
}
const result = new Set();
for ( let i = 0, l = arr.length; i < l; i ++ ) {
arr[ i ].traverse( c => {
if ( c.geometry && c.visible ) {
result.add( c );
}
} );
}
return Array.from( result );
}
================================================
FILE: src/utils/getProjectedLineOverlap.js
================================================
import { Vector3, Line3, Plane } from 'three';
import { ExtendedTriangle } from 'three-mesh-bvh';
const AREA_EPSILON = 1e-16;
const DIST_EPSILON = 1e-16;
const _orthoPlane = /* @__PURE__ */ new Plane();
const _edgeLine = /* @__PURE__ */ new Line3();
const _point = /* @__PURE__ */ new Vector3();
const _vec = /* @__PURE__ */ new Vector3();
const _tri = /* @__PURE__ */ new ExtendedTriangle();
const _line = /* @__PURE__ */ new Line3();
const _triLine = /* @__PURE__ */ new Line3();
const _dir = /* @__PURE__ */ new Vector3();
const _ortho = /* @__PURE__ */ new Vector3();
const _triDir = /* @__PURE__ */ new Vector3();
// Returns the portion of the line that is overlapping the triangle when projected
// TODO: rename this, remove need for tri update, plane
export function getProjectedLineOverlap( line, triangle, lineTarget = new Line3() ) {
// flatten the shapes
_tri.copy( triangle );
_tri.a.y = 0;
_tri.b.y = 0;
_tri.c.y = 0;
_tri.update();
_line.copy( line );
_line.start.y = 0;
_line.end.y = 0;
// if the triangle is degenerate then return no overlap
if ( _tri.getArea() <= AREA_EPSILON ) {
return null;
}
const lineDistance = _line.distance();
_line.delta( _dir ).divideScalar( lineDistance );
_ortho.copy( _dir ).cross( _tri.plane.normal ).normalize();
_orthoPlane.setFromNormalAndCoplanarPoint( _ortho, _line.start );
// find the line of intersection of the triangle along the plane if it exists
let intersectCount = 0;
const { points } = _tri;
for ( let i = 0; i < 3; i ++ ) {
const p1 = points[ i ];
const p2 = points[ ( i + 1 ) % 3 ];
const distToStart = _orthoPlane.distanceToPoint( p1 );
const distToEnd = _orthoPlane.distanceToPoint( p2 );
const startIntersects = Math.abs( distToStart ) < DIST_EPSILON;
const endIntersects = Math.abs( distToEnd ) < DIST_EPSILON;
if ( startIntersects && endIntersects ) {
continue;
} else if ( startIntersects ) {
_point.copy( p1 );
} else if ( endIntersects ) {
continue;
} else if ( ( distToStart < 0.0 ) == ( distToEnd < 0.0 ) ) {
continue;
} else {
// manual edge-plane intersection (faster than Plane.intersectLine)
const t = distToStart / ( distToStart - distToEnd );
_point.lerpVectors( p1, p2, t );
}
if ( intersectCount == 0 ) {
_triLine.start.copy( _point );
} else if ( intersectCount == 1 ) {
_triLine.end.copy( _point );
}
intersectCount ++;
if ( intersectCount === 2 ) {
break;
}
}
if ( intersectCount === 2 ) {
// find the intersect line if any
_triLine.delta( _triDir ).normalize();
// swap edges so they're facing in the same direction
if ( _dir.dot( _triDir ) < 0 ) {
const tmp = _triLine.start;
_triLine.start = _triLine.end;
_triLine.end = tmp;
}
// check if the edges are overlapping
const s1 = 0;
const e1 = _vec.subVectors( _line.end, _line.start ).dot( _dir );
const s2 = _vec.subVectors( _triLine.start, _line.start ).dot( _dir );
const e2 = _vec.subVectors( _triLine.end, _line.start ).dot( _dir );
const separated1 = e1 <= s2;
const separated2 = e2 <= s1;
if ( separated1 || separated2 ) {
return null;
}
line.at(
Math.max( s1, s2 ) / lineDistance,
lineTarget.start,
);
line.at(
Math.min( e1, e2 ) / lineDistance,
lineTarget.end,
);
return lineTarget;
}
return null;
}
================================================
FILE: src/utils/getProjectedOverlaps.js
================================================
import { Vector3 } from 'three';
const DIST_EPSILON = 1e-16;
const _dir = /* @__PURE__ */ new Vector3();
const _v0 = /* @__PURE__ */ new Vector3();
const _v1 = /* @__PURE__ */ new Vector3();
export function appendOverlapRange( line, overlapLine, overlapsTarget ) {
const result = getOverlapRange( line, overlapLine );
if ( result ) {
insertOverlap( result, overlapsTarget );
return true;
}
return false;
}
// Returns the overlap range without pushing to array (for binary insertion)
export function getOverlapRange( line, overlapLine ) {
line.delta( _dir );
_v0.subVectors( overlapLine.start, line.start );
_v1.subVectors( overlapLine.end, line.start );
const length = _dir.length();
let t0 = _v0.length() / length;
let t1 = _v1.length() / length;
t0 = Math.min( Math.max( t0, 0 ), 1 );
t1 = Math.min( Math.max( t1, 0 ), 1 );
if ( Math.abs( t0 - t1 ) <= DIST_EPSILON ) {
return null;
}
return [ t0, t1 ];
}
export function insertOverlap( result, overlapsTarget ) {
let [ start, end ] = result;
// binary search to find where the for loop should begin iteration
let left = 0;
let right = overlapsTarget.length;
while ( left < right ) {
const mid = ( left + right ) >>> 1;
if ( overlapsTarget[ mid ][ 0 ] <= start ) {
left = mid + 1;
} else {
right = mid;
}
}
// start iteration from one position before (in case previous overlap
// extends into ours)
let insertPoint = Math.max( 0, left - 1 );
let deleteCount = 0;
for ( let i = insertPoint, l = overlapsTarget.length; i < l; i ++ ) {
const [ otherStart, otherEnd ] = overlapsTarget[ i ];
if ( start <= otherEnd && end >= otherStart ) {
// check if there's overlap
start = Math.min( otherStart, start );
end = Math.max( otherEnd, end );
deleteCount ++;
} else if ( start >= otherStart ) {
// otherwise move the insertion point forward
insertPoint = i + 1;
} else {
break;
}
}
overlapsTarget.splice( insertPoint, deleteCount, [ start, end ] );
}
================================================
FILE: src/utils/getSizeSortedTriList.js
================================================
import { Triangle } from 'three';
import { getTriCount } from './geometryUtils.js';
const _tri = new Triangle();
export function getSizeSortedTriList( geometry ) {
const index = geometry.index;
const posAttr = geometry.attributes.position;
const triCount = getTriCount( geometry );
return new Array( triCount )
.fill()
.map( ( v, i ) => {
let i0 = i * 3 + 0;
let i1 = i * 3 + 1;
let i2 = i * 3 + 2;
if ( index ) {
i0 = index.getX( i0 );
i1 = index.getX( i1 );
i2 = index.getX( i2 );
}
_tri.a.fromBufferAttribute( posAttr, i0 );
_tri.b.fromBufferAttribute( posAttr, i1 );
_tri.c.fromBufferAttribute( posAttr, i2 );
_tri.a.y = 0;
_tri.b.y = 0;
_tri.c.y = 0;
// get the projected area of the triangle to sort largest triangles first
return {
area: _tri.getArea(),
index: i,
};
} )
.sort( ( a, b ) => {
// sort the triangles largest to smallest
return b.area - a.area;
} )
.map( o => {
// map to the triangle index
return o.index;
} );
}
================================================
FILE: src/utils/nextFrame.js
================================================
export const nextFrame = () => new Promise( resolve => {
let rafHandle;
let timeoutHandle;
const cb = () => {
cancelAnimationFrame( rafHandle );
clearTimeout( timeoutHandle );
resolve();
};
rafHandle = requestAnimationFrame( cb );
timeoutHandle = setTimeout( cb, 16 );
} );
================================================
FILE: src/utils/overlapUtils.js
================================================
import { Line3 } from 'three';
const _line = /* @__PURE__ */ new Line3();
// Converts the given array of overlaps into line segments
export function overlapsToLines( line, overlaps, invert = false, target = [] ) {
// Function assumes the line overlaps are already compressed
let invOverlaps = [[ 0, 1 ]];
for ( let i = 0, l = overlaps.length; i < l; i ++ ) {
const invOverlap = invOverlaps[ i ];
const overlap = overlaps[ i ];
invOverlap[ 1 ] = overlap[ 0 ];
invOverlaps.push( [ overlap[ 1 ], 1 ] );
}
if ( invert ) {
[ overlaps, invOverlaps ] = [ invOverlaps, overlaps ];
}
for ( let i = 0, l = invOverlaps.length; i < l; i ++ ) {
const { start, end } = line;
_line.start.lerpVectors( start, end, invOverlaps[ i ][ 0 ] );
_line.end.lerpVectors( start, end, invOverlaps[ i ][ 1 ] );
target.push( new Float32Array( [
_line.start.x,
_line.start.y,
_line.start.z,
_line.end.x,
_line.end.y,
_line.end.z,
] ) );
}
return invOverlaps.length;
}
================================================
FILE: src/utils/planeUtils.js
================================================
import { Vector3, Line3 } from "three";
const _line = /* @__PURE__ */ new Line3();
const _v0 = /* @__PURE__ */ new Vector3();
const _v1 = /* @__PURE__ */ new Vector3();
// returns the the y value on the plane at the given point x, z
export function getPlaneYAtPoint( plane, point, target = null ) {
_line.start.copy( point );
_line.end.copy( point );
_line.start.y += 1e5;
_line.end.y -= 1e5;
plane.intersectLine( _line, target );
}
// returns whether the given line is above the given triangle plane
export function isLineAbovePlane( plane, line ) {
const linePoint = _v0;
const planePoint = _v1;
linePoint.lerpVectors( line.start, line.end, 0.5 );
getPlaneYAtPoint( plane, linePoint, planePoint );
return planePoint.y < linePoint.y;
}
================================================
FILE: src/utils/triangleIsInsidePaths.js
================================================
import { Line3, Ray } from 'three';
function xzToXzCopy( v, target ) {
target.x = v.x;
target.y = v.z;
}
function epsEquals( a, b ) {
return Math.abs( a - b ) <= 500;
}
function vectorEpsEquals( v0, v1 ) {
return epsEquals( v0.x, v1.x ) &&
epsEquals( v0.y, v1.y ) &&
epsEquals( v0.z, v1.z );
}
export function triangleIsInsidePaths( tri, paths ) {
const indices = [ 'a', 'b', 'c' ];
const edges = [ new Line3(), new Line3(), new Line3() ];
const line = new Line3();
const ray = new Line3();
ray.start
.set( 0, 0, 0 )
.addScaledVector( tri.a, 1 / 3 )
.addScaledVector( tri.b, 1 / 3 )
.addScaledVector( tri.c, 1 / 3 );
xzToXzCopy( ray.start, ray.start );
ray.end.copy( ray.start );
ray.end.y += 1e10;
// get all triangle edges
for ( let i = 0; i < 3; i ++ ) {
const i1 = ( i + 1 ) % 3;
const p0 = tri[ indices[ i ] ];
const p1 = tri[ indices[ i1 ] ];
const edge = edges[ i ];
xzToXzCopy( p0, edge.start );
xzToXzCopy( p1, edge.end );
}
let crossCount = 0;
for ( let p = 0, pl = paths.length; p < pl; p ++ ) {
const points = paths[ p ];
for ( let i = 0, l = points.length; i < l; i ++ ) {
const i1 = ( i + 1 ) % l;
line.start.copy( points[ i ] );
line.start.z = 0;
line.end.copy( points[ i1 ] );
line.end.z = 0;
if ( lineCrossesLine( ray, line ) ) {
crossCount ++;
}
for ( let e = 0; e < 3; e ++ ) {
const edge = edges[ e ];
if (
lineCrossesLine( edge, line ) ||
vectorEpsEquals( edge.start, line.start ) ||
vectorEpsEquals( edge.end, line.end ) ||
vectorEpsEquals( edge.end, line.start ) ||
vectorEpsEquals( edge.start, line.end )
) {
return false;
}
}
}
}
return crossCount % 2 === 1;
}
// https://stackoverflow.com/questions/3838329/how-can-i-check-if-two-segments-intersect
function lineCrossesLine( l1, l2 ) {
function ccw( A, B, C ) {
return ( C.y - A.y ) * ( B.x - A.x ) > ( B.y - A.y ) * ( C.x - A.x );
}
const A = l1.start;
const B = l1.end;
const C = l2.start;
const D = l2.end;
return ccw( A, C, D ) !== ccw( B, C, D ) && ccw( A, B, C ) !== ccw( A, B, D );
}
================================================
FILE: src/utils/triangleLineUtils.js
================================================
import { Vector3 } from 'three';
const EPSILON = 1e-16;
const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
const _dir = new Vector3();
export function isYProjectedLineDegenerate( line ) {
line.delta( _dir ).normalize();
return Math.abs( _dir.dot( UP_VECTOR ) ) >= 1.0 - EPSILON;
}
// checks whether the y-projected triangle will be degenerate
export function isYProjectedTriangleDegenerate( tri ) {
if ( tri.needsUpdate ) {
tri.update();
}
return Math.abs( tri.plane.normal.dot( UP_VECTOR ) ) <= EPSILON;
}
// Is the provided line exactly an edge on the triangle
export function isLineTriangleEdge( tri, line ) {
// if this is the same line as on the triangle
const { start, end } = line;
const triPoints = tri.points;
let startMatches = false;
let endMatches = false;
for ( let i = 0; i < 3; i ++ ) {
const tp = triPoints[ i ];
if ( ! startMatches && start.distanceToSquared( tp ) <= EPSILON ) {
startMatches = true;
}
if ( ! endMatches && end.distanceToSquared( tp ) <= EPSILON ) {
endMatches = true;
}
if ( startMatches && endMatches ) {
return true;
}
}
return startMatches && endMatches;
}
================================================
FILE: src/utils/trimToBeneathTriPlane.js
================================================
import { Plane, Vector3, MathUtils } from 'three';
const EPSILON = 1e-16;
const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
const _plane = /* @__PURE__ */ new Plane();
const _planeHit = /* @__PURE__ */ new Vector3();
const _lineDirection = /* @__PURE__ */ new Vector3();
export function trimToBeneathTriPlane( tri, line, lineTarget ) {
// update triangle if needed
if ( tri.needsUpdate ) {
tri.update();
}
// if the plane is not facing up then flip the direction
_plane.copy( tri.plane );
if ( _plane.normal.dot( UP_VECTOR ) < 0 ) {
_plane.normal.multiplyScalar( - 1 );
_plane.constant *= - 1;
}
const startDist = _plane.distanceToPoint( line.start );
const endDist = _plane.distanceToPoint( line.end );
const isStartOnPlane = Math.abs( startDist ) < EPSILON;
const isEndOnPlane = Math.abs( endDist ) < EPSILON;
const isStartBelow = startDist < 0;
const isEndBelow = endDist < 0;
// if the line and plane are coplanar then return that we can't trim
line.delta( _lineDirection ).normalize();
if ( Math.abs( _plane.normal.dot( _lineDirection ) ) < EPSILON ) {
// if the line is definitely above or on the plane then skip it
if ( isStartOnPlane || ! isStartBelow ) {
return false;
} else {
lineTarget.copy( line );
return true;
}
}
// find the point that's below the plane. If both points are below the plane
// then we assume we're dealing with floating point error
if ( isStartBelow && isEndBelow ) {
// if the whole line is below then just copy that
lineTarget.copy( line );
return true;
} else if ( ! isStartBelow && ! isEndBelow ) {
// if it's wholly above then skip it
return false;
} else {
const t = MathUtils.mapLinear( 0, startDist, endDist, 0, 1 );
line.at( t, _planeHit );
if ( isStartBelow ) {
lineTarget.start.copy( line.start );
lineTarget.end.copy( _planeHit );
return true;
} else if ( isEndBelow ) {
lineTarget.end.copy( line.end );
lineTarget.start.copy( _planeHit );
return true;
}
}
return false;
}
================================================
FILE: src/webgpu/MeshVisibilityCuller.js
================================================
/** @import { Object3D } from 'three' */
/** @import { WebGPURenderer } from 'three/webgpu' */
import {
Box3,
Vector3,
Vector4,
OrthographicCamera,
Color,
Mesh,
} from 'three';
import { RenderTarget, MeshBasicNodeMaterial } from 'three/webgpu';
import { uniform } from 'three/tsl';
import { getAllMeshes } from '../utils/getAllMeshes.js';
// RGBA8 ID encoding - supports up to 16,777,215 objects (2^24 - 1)
// ID 0 is valid, background is indicated by alpha = 0
function encodeId( id, target ) {
target.x = ( id & 0xFF ) / 255;
target.y = ( ( id >> 8 ) & 0xFF ) / 255;
target.z = ( ( id >> 16 ) & 0xFF ) / 255;
target.w = 1;
}
function decodeId( buffer, index ) {
return buffer[ index ] | ( buffer[ index + 1 ] << 8 ) | ( buffer[ index + 2 ] << 16 );
}
/**
* Utility for determining visible geometry from a top down orthographic perspective. This can
* be run before performing projection generation to reduce the complexity of the operation at
* the cost of potentially missing small details.
*
* Takes the WebGPURenderer instance used to render.
* @param {WebGPURenderer} renderer
* @param {Object} [options]
* @param {number} [options.pixelsPerMeter=0.1]
*/
export class MeshVisibilityCuller {
constructor( renderer, options = {} ) {
const { pixelsPerMeter = 0.1 } = options;
/**
* The size of a pixel on a single dimension. If this results in a texture larger than what
* the graphics context can provide then the rendering is tiled.
* @type {number}
*/
this.pixelsPerMeter = pixelsPerMeter;
this.renderer = renderer;
}
/**
* Returns the set of meshes that are visible within the given object.
* @param {Object3D|Array<Object3D>} object
* @returns {Promise<Array<Object3D>>}
*/
async cull( objects ) {
objects = getAllMeshes( objects );
const { renderer, pixelsPerMeter } = this;
const size = new Vector3();
const camera = new OrthographicCamera();
const box = new Box3();
const idValue = new Vector4();
const idUniform = uniform( idValue );
const idMaterial = new MeshBasicNodeMaterial();
idMaterial.colorNode = idUniform;
const idMesh = new Mesh( undefined, idMaterial );
idMesh.matrixAutoUpdate = false;
idMesh.matrixWorldAutoUpdate = false;
// get the bounds of the image
box.makeEmpty();
objects.forEach( o => {
box.expandByObject( o );
} );
// get the bounds dimensions
box.getSize( size );
// calculate the tile and target size
const maxTextureSize = Math.min( renderer.backend.device.limits.maxTextureDimension2D, 2 ** 13 );
const pixelWidth = Math.ceil( size.x / pixelsPerMeter );
const pixelHeight = Math.ceil( size.z / pixelsPerMeter );
const tilesX = Math.ceil( pixelWidth / maxTextureSize );
const tilesY = Math.ceil( pixelHeight / maxTextureSize );
const target = new RenderTarget( Math.ceil( pixelWidth / tilesX ), Math.ceil( pixelHeight / tilesY ) );
// set the camera bounds
camera.rotation.x = - Math.PI / 2;
camera.far = ( box.max.y - box.min.y ) + camera.near;
camera.position.y = box.max.y + camera.near;
// save render state
const color = renderer.getClearColor( new Color() );
const alpha = renderer.getClearAlpha();
const renderTarget = renderer.getRenderTarget();
const autoClear = renderer.autoClear;
// render ids
const visibleSet = new Set();
const stepX = size.x / tilesX;
const stepZ = size.z / tilesY;
for ( let x = 0; x < tilesX; x ++ ) {
for ( let y = 0; y < tilesY; y ++ ) {
// update camera
camera.left = box.min.x + stepX * x;
camera.top = - ( box.min.z + stepZ * y );
camera.right = camera.left + stepX;
camera.bottom = camera.top - stepZ;
camera.updateProjectionMatrix();
// clear the render target
renderer.autoClear = false;
renderer.setClearColor( 0, 0 );
renderer.setRenderTarget( target );
renderer.clear();
for ( let i = 0; i < objects.length; i ++ ) {
const object = objects[ i ];
idMesh.matrixWorld.copy( object.matrixWorld );
idMesh.geometry = object.geometry;
encodeId( i, idValue );
renderer.render( idMesh, camera );
}
// reset render state before async operation to avoid corruption
renderer.setClearColor( color, alpha );
renderer.setRenderTarget( renderTarget );
renderer.autoClear = autoClear;
const buffer = new Uint8Array( await renderer.readRenderTargetPixelsAsync( target, 0, 0, target.width, target.height ) );
// find all visible objects - decode RGBA to ID
for ( let i = 0, l = buffer.length; i < l; i += 4 ) {
// alpha = 0 indicates background (no object)
if ( buffer[ i + 3 ] === 0 ) continue;
const id = decodeId( buffer, i );
visibleSet.add( objects[ id ] );
}
}
}
// dispose of intermediate values
idMaterial.dispose();
target.dispose();
return Array.from( visibleSet );
}
}
================================================
FILE: src/webgpu/ProjectionGenerator.js
================================================
/** @import { Object3D, BufferGeometry } from 'three' */
/** @import { WebGPURenderer } from 'three/webgpu' */
import { IndirectStorageBufferAttribute, ReadbackBuffer, StorageBufferAttribute } from 'three/webgpu';
import { storage } from 'three/tsl';
import { getAllMeshes } from '../utils/getAllMeshes.js';
import { EdgeGenerator } from '../EdgeGenerator.js';
import { isYProjectedLineDegenerate } from '../utils/triangleLineUtils.js';
import { ProjectionGeneratorBVHComputeData } from './ProjectionGeneratorBVHComputeData.js';
import { edgeStruct, overlapRecordStruct } from './nodes/structs.wgsl.js';
import { EdgeOverlapsKernel } from './kernels/EdgeOverlapsKernel.js';
import { overlapsToLines } from '../utils/overlapUtils.js';
import { insertOverlap } from '../utils/getProjectedOverlaps.js';
import { ProjectionResult } from '../ProjectionGenerator.js';
import { ZeroOutBufferKernel } from './kernels/ZeroOutBufferKernel.js';
import { nextFrame } from '../utils/nextFrame.js';
// TODO: Consider storing the ranges with multiple edges clipped per thread to reduce the array size needed
const MAX_BUFFER_SIZE = 134217728;
const MAX_OVERLAPS_COUNT = Math.floor( MAX_BUFFER_SIZE / ( overlapRecordStruct.getLength() * 4 ) );
/**
* @callback ProjectionProgressCallback
* @param {number} percent
* @param {string} message
*/
/**
* Takes the WebGPURenderer instance used to run compute kernels.
* @param {WebGPURenderer} renderer
*/
export class ProjectionGenerator {
constructor( renderer ) {
this.renderer = renderer;
/**
* The threshold angle in degrees at which edges are generated.
* @type {number}
* @default 50
*/
this.angleThreshold = 50;
/**
* The number of edges to process in one compute kernel pass. Larger values can process
* faster but may cause internal buffers to overflow, resulting in extra kernel executions,
* taking more time.
* @type {number}
* @default 100000
*/
this.batchSize = 100000;
/**
* Whether to generate edges representing the intersections between triangles.
* @type {boolean}
* @default true
*/
this.includeIntersectionEdges = true;
/**
* How long to spend generating edges.
* @type {number}
* @default 300
*/
this.iterationTime = 300;
/**
* How many compute jobs to perform in parallel.
* @type {number}
* @default 3
*/
this.parallelJobs = 3;
}
/**
* Asynchronously generate the edge geometry result.
* @param {Object3D|BufferGeometry|Array<Object3D>} scene
* @param {Object} [options]
* @param {ProjectionProgressCallback} [options.onProgress]
* @param {AbortSignal} [options.signal]
* @returns {Promise<ProjectionResult>}
*/
async generate( scene, options = {} ) {
const { renderer, angleThreshold, includeIntersectionEdges, batchSize, iterationTime, parallelJobs } = this;
const { onProgress = null, signal = null } = options;
// collect meshes
const meshes = getAllMeshes( scene );
// generate edges
const edgeGenerator = new EdgeGenerator();
edgeGenerator.thresholdAngle = angleThreshold;
edgeGenerator.iterationTime = iterationTime;
// adjust the offset to account for floating point error in the edge processing and intersections.
// NOTE: Ideally we should be applying this relative to the scale of the values being used rather that
// using a fixed offset.
edgeGenerator.yOffset = 5 * 1e-5;
if ( onProgress ) {
onProgress( 0, 'Generating Edges' );
}
let edges = [];
await edgeGenerator.getEdgesAsync( scene, edges );
signal?.throwIfAborted();
if ( includeIntersectionEdges ) {
if ( onProgress ) {
onProgress( 0, 'Generating Intersection Edges' );
}
await edgeGenerator.getIntersectionEdgesAsync( scene, edges );
signal?.throwIfAborted();
}
edges = edges.filter( e => ! isYProjectedLineDegenerate( e ) );
if ( edges.length === 0 ) {
return new ProjectionResult();
}
onProgress( 0, 'Projecting Edges' );
//
// allocate a buffer of edges for at most the requested capacity
const batchCapacity = Math.min( batchSize, edges.length );
const edgeBufferData = new Float32Array( batchCapacity * edgeStruct.getLength() );
const edgeBufferDataU32 = new Uint32Array( edgeBufferData.buffer );
const edgeBufferAttribute = new StorageBufferAttribute( edgeBufferData, edgeStruct.getLength() );
// overlap output buffer and atomic counter
const overlapsAttribute = new IndirectStorageBufferAttribute( MAX_OVERLAPS_COUNT, overlapRecordStruct.getLength(), Uint32Array );
const bufferPointersAttribute = new IndirectStorageBufferAttribute( 1, 1 );
const overflowFlagAttribute = new IndirectStorageBufferAttribute( 1, 1 );
const overlapsStorage = storage( overlapsAttribute, overlapRecordStruct ).setName( 'overlaps' );
const bufferPointersStorage = storage( bufferPointersAttribute, 'uint' ).toAtomic();
const overflowFlagStorage = storage( overflowFlagAttribute, 'uint' ).setName( 'overflowFlag' ).toAtomic();
//
// set up scene data
const bvhComputeData = new ProjectionGeneratorBVHComputeData( meshes );
bvhComputeData.update();
bvhComputeData.fns.collectEdgeOverlaps = bvhComputeData.getCollectEdgeOverlapsFn( {
overlapsStorage: overlapsStorage,
bufferPointersStorage: bufferPointersStorage,
overflowFlagStorage: overflowFlagStorage,
} );
// initialize kernels
const edgeOverlapsKernel = new EdgeOverlapsKernel();
edgeOverlapsKernel.setWorkgroupSize( 64, 1, 1 );
edgeOverlapsKernel.edges = edgeBufferAttribute;
edgeOverlapsKernel.bvhData = bvhComputeData;
const zeroOutKernel = new ZeroOutBufferKernel();
zeroOutKernel.setWorkgroupSize( 1, 1, 1 );
//
const intervalsByEdge = new Map();
let progress = 0;
const promises = [];
const edgeStructStride = edgeStruct.getLength();
// register abort callback
const onAbort = () => jobQueue.cancelAll();
signal?.addEventListener( 'abort', onAbort );
// job queue and readback buffers to save memory, improve performance
const readbackBufferPool = [];
const jobQueue = new JobQueue();
jobQueue.maxJobs = parallelJobs;
const runJob = async ( start, count ) => {
if ( signal?.aborted ) {
return;
}
// fill out the edges array
for ( let i = 0; i < count; i ++ ) {
const edge = edges[ start + i ];
const offset = i * edgeStructStride;
edge.start.toArray( edgeBufferData, offset );
edge.end.toArray( edgeBufferData, offset + 3 );
edgeBufferDataU32[ offset + 6 ] = i;
}
edgeBufferAttribute.needsUpdate = true;
// clear the overlaps counter and overflow flag
zeroOutKernel.target = bufferPointersAttribute;
renderer.compute( zeroOutKernel.kernel, [ 1, 1, 1 ] );
zeroOutKernel.target = overflowFlagAttribute;
renderer.compute( zeroOutKernel.kernel, [ 1, 1, 1 ] );
// traverse BVH and write overlaps directly
edgeOverlapsKernel.edgesToProcess = count;
renderer.compute( edgeOverlapsKernel.kernel, edgeOverlapsKernel.getDispatchSize( count ) );
let readbackBuffer;
if ( readbackBufferPool.length !== 0 ) {
readbackBuffer = readbackBufferPool.pop();
} else {
readbackBuffer = new ReadbackBuffer( MAX_BUFFER_SIZE );
}
const [ overlaps, bufferPointers, overflowBuffer ] = await Promise.all( [
renderer.getArrayBufferAsync( overlapsAttribute, readbackBuffer ),
renderer.getArrayBufferAsync( bufferPointersAttribute ),
renderer.getArrayBufferAsync( overflowFlagAttribute ),
] );
// add the readback buffer back to the pool if we've aborted this run
if ( signal?.aborted ) {
readbackBuffer.release();
readbackBufferPool.push( readbackBuffer );
return;
}
const overflow = new Uint32Array( overflowBuffer )[ 0 ];
if ( overflow > 0 ) {
if ( count === 1 ) {
console.error( `ProjectionGenerator: Overlaps buffer insufficient size to store all segments. Please report to three-edge-projection.` );
} else {
// split the job in half and re-queue both halves
const half = Math.ceil( count / 2 );
promises.push( jobQueue.add( runJob, [ start, half ] ) );
promises.push( jobQueue.add( runJob, [ start + half, count - half ] ) );
readbackBuffer.release();
readbackBufferPool.push( readbackBuffer );
return;
}
}
// read buffers
const overlapsF32 = new Float32Array( overlaps.buffer );
const overlapsU32 = new Uint32Array( overlaps.buffer );
const bufferPointersU32 = new Uint32Array( bufferPointers );
const stride = overlapRecordStruct.getLength();
// push the overlaps
for ( let oi = 0, ol = bufferPointersU32[ 0 ]; oi < ol; oi ++ ) {
const index = oi * stride;
const ei = start + overlapsU32[ index + 0 ];
const t0 = overlapsF32[ index + 1 ];
const t1 = overlapsF32[ index + 2 ];
if ( ! intervalsByEdge.has( ei ) ) {
intervalsByEdge.set( ei, [] );
}
insertOverlap( [ t0, t1 ], intervalsByEdge.get( ei ) );
}
progress += count;
// fire progress
if ( onProgress ) {
onProgress( progress / edges.length, 'Projecting Edges' );
}
// release the buffer to the pool
readbackBuffer.release();
readbackBufferPool.push( readbackBuffer );
};
// enqueue initial jobs
for ( let e = 0; e < edges.length; e += batchCapacity ) {
promises.push( jobQueue.add( runJob, [ e, Math.min( batchCapacity, edges.length - e ) ] ) );
}
// drain — sequential iteration naturally picks up overflow sub-jobs added to promises
try {
for ( let i = 0; i < promises.length; i ++ ) {
await promises[ i ];
}
} finally {
signal?.removeEventListener( 'abort', onAbort );
// overlapsAttribute.dispose();
// bufferPointersAttribute.dispose();
// overflowFlagAttribute.dispose();
// edgeBufferAttribute.dispose();
// dispose of all the readback buffers
readbackBufferPool.forEach( rb => rb.dispose() );
}
signal?.throwIfAborted();
// push all edges to the "results" object
const collector = new ProjectionResult();
for ( let i = 0; i < edges.length; i ++ ) {
const mesh = edges[ i ].mesh;
if ( ! collector.visibleEdges.meshToSegments.has( mesh ) ) {
collector.visibleEdges.meshToSegments.set( mesh, [] );
collector.hiddenEdges.meshToSegments.set( mesh, [] );
}
const intervals = intervalsByEdge.get( i ) || [];
overlapsToLines( edges[ i ], intervals, false, collector.visibleEdges.meshToSegments.get( mesh ) );
overlapsToLines( edges[ i ], intervals, true, collector.hiddenEdges.meshToSegments.get( mesh ) );
}
return collector;
}
}
class JobQueue {
constructor() {
this.queue = [];
this.maxJobs = 3;
this.currJobs = 0;
this._scheduled = false;
}
add( cb, args ) {
return new Promise( ( resolve, reject ) => {
this.queue.push( {
run: () => {
const res = cb( ...args );
res
.then( resolve )
.catch( reject );
return res;
},
reject,
} );
this.scheduleRun();
} );
}
cancelAll() {
const { queue } = this;
while ( queue.length > 0 ) {
const entry = queue.shift();
entry.reject( new Error( 'JobQueue: cancelled' ) );
}
}
async runJobs() {
const { queue } = this;
while ( this.currJobs < this.maxJobs ) {
if ( queue.length === 0 ) {
return;
}
this.currJobs ++;
await nextFrame();
const entry = queue.shift();
entry.run()
.finally( () => {
this.currJobs --;
this.scheduleRun();
} );
}
}
scheduleRun() {
if ( this._scheduled ) {
return;
}
this._scheduled = true;
requestAnimationFrame( async () => {
await this.runJobs();
this._scheduled = false;
} );
}
}
================================================
FILE: src/webgpu/ProjectionGeneratorBVHComputeData.js
================================================
import { BackSide, DoubleSide, FrontSide } from 'three';
import { StructTypeNode } from 'three/webgpu';
import { BVHComputeData } from './lib/BVHComputeData.js';
import { wgslTagFn } from './lib/nodes/WGSLTagFnNode.js';
import { bvhNodeBoundsStruct } from './lib/wgsl/structs.wgsl.js';
import { transformBVHBounds } from './nodes/utils.wgsl.js';
import { constants as overlapConstants } from './nodes/common.wgsl.js';
import { getProjectedOverlapRange, isLineTriangleEdge, trimToBeneathTriPlane } from './nodes/overlapFunctions.wgsl.js';
import { LineWGSL, TriWGSL } from './nodes/primitives.js';
// Shape struct carrying world-space line endpoints plus the object-to-world
// matrix (set by transformShapeFn; identity at top level so world-space
// bounds pass through unchanged) and the transform buffer index.
const edgeLineShapeStruct = new StructTypeNode( {
worldStart: 'vec3f',
worldEnd: 'vec3f',
matrixWorld: 'mat4x4f',
objectIndex: 'uint',
edgeIndex: 'uint',
}, 'EdgeLineShape' );
// Extended transform struct that adds a per-object "side" field for back-face
// culling. Values: 0 = DoubleSide (no cull), 1 = FrontSide, -1 = BackSide.
const projectionTransformStruct = new StructTypeNode( {
matrixWorld: 'mat4x4f',
inverseMatrixWorld: 'mat4x4f',
nodeOffset: 'uint',
visible: 'uint',
side: 'int',
_alignment0: 'uint',
}, 'ProjectionTransformStruct' );
// Projection-generator-specific BVHComputeData that only requires position
// attributes and auto-generates missing BVHs.
export class ProjectionGeneratorBVHComputeData extends BVHComputeData {
constructor( bvh, options = {} ) {
super( bvh, {
attributes: { position: 'vec4f' },
...options,
} );
this.bvhMap = new Map();
this.structs.transform = projectionTransformStruct;
this._sharedFns = null;
this._fns = null;
}
writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer ) {
super.writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer );
const { object, root } = info;
let material = object.material;
if ( Array.isArray( material ) ) {
material = material[ object.geometry.groups[ root ].materialIndex ];
}
let sideValue;
switch ( material.side ) {
case DoubleSide:
sideValue = 0;
break;
case FrontSide:
sideValue = 1;
break;
case BackSide:
sideValue = - 1;
break;
}
const transformBufferU32 = new Uint32Array( targetBuffer );
transformBufferU32[ writeOffset * projectionTransformStruct.getLength() + 34 ] = sideValue;
}
update() {
super.update();
this.bvhMap.clear();
this._sharedFns = null;
this._fns = null;
}
// Returns a WGSL function — fn traverse( edgeIndex, lineStart, lineEnd ) -> void —
// that traverses the BVH for one edge and writes qualifying { edgeIndex, objectIndex, triIndex }
// records to the pairs buffer using atomic slot claiming.
//
// pairsCountsStorage is a 2-element array<atomic<u32>>:
// [0] write offset — claimed unconditionally via atomicAdd
// [1] dispatch count — incremented only when the claimed slot is within capacity; equals
// the number of valid pair records written and is used as K3's dispatch bound
//
// overflowFlagStorage is a 1-element array<atomic<u32>> that accumulates the number of
// pairs that could not be written due to buffer overflow.
//
// NOTE: pairsCountsStorage must be bound as array<atomic<u32>> (read_write storage).
getCollectEdgeOverlapsFn( { overlapsStorage, bufferPointersStorage, overflowFlagStorage } ) {
const { storage } = this;
const { DOUBLE_SIDE, BACK_SIDE, DIST_THRESHOLD } = overlapConstants;
const intersectsBoundsFn = wgslTagFn/* wgsl */`
fn intersectsBounds( shape: ${ edgeLineShapeStruct }, bounds: ${ bvhNodeBoundsStruct } ) -> u32 {
// TODO: a proper 3D Line / AABB check with the bottom of the bounds extended downward
// would be best here since we are getting some false positives.
// Transform bounds to world space. At the top level the shape matrix
// is identity, so world-space bounds pass through unchanged.
let aabb = ${ transformBVHBounds }( bounds, shape.matrixWorld );
let aabbMin = vec3( aabb.min[ 0 ], aabb.min[ 1 ], aabb.min[ 2 ] );
let aabbMax = vec3( aabb.max[ 0 ], aabb.max[ 1 ], aabb.max[ 2 ] );
// Y-cull: bounds entirely below the line
if ( aabbMax.y <= min( shape.worldStart.y, shape.worldEnd.y ) ) {
return 0u;
}
// AABB vs AABB test
let lineMinX = min( shape.worldStart.x, shape.worldEnd.x );
let lineMaxX = max( shape.worldStart.x, shape.worldEnd.x );
let lineMinZ = min( shape.worldStart.z, shape.worldEnd.z );
let lineMaxZ = max( shape.worldStart.z, shape.worldEnd.z );
if (
aabbMax.x < lineMinX || aabbMin.x > lineMaxX ||
aabbMax.z < lineMinZ || aabbMin.z > lineMaxZ
) {
return 0u;
}
// edge SAT axis
let segDelta = shape.worldEnd.xz - shape.worldStart.xz;
let segNormal = vec2f( - segDelta.y, segDelta.x );
let segProj = dot( segNormal, vec2f( shape.worldStart.x, shape.worldStart.z ) );
let aabbCenter = ( aabbMin.xz + aabbMax.xz ) * 0.5;
let aabbHalf = ( aabbMax.xz - aabbMin.xz ) * 0.5;
let aabbCenterProj = dot( segNormal, aabbCenter );
let aabbHalfProj = dot( abs( segNormal ), aabbHalf );
if ( abs( aabbCenterProj - segProj ) > aabbHalfProj ) {
return 0u;
}
return 1u;
}
`;
const transformShapeFn = wgslTagFn/* wgsl */`
fn transformShape( localShape: ptr<function, ${ edgeLineShapeStruct }>, objectIndex: u32 ) -> void {
localShape.matrixWorld = ${ storage.transforms }[ objectIndex ].matrixWorld;
localShape.objectIndex = objectIndex;
}
`;
const intersectRangeFn = wgslTagFn/* wgsl */`
fn traverseRange( shape: ${ edgeLineShapeStruct }, offset: u32, count: u32 ) -> bool {
var tri: ${ TriWGSL.struct };
var line: ${ LineWGSL.struct };
line.start = shape.worldStart;
line.end = shape.worldEnd;
let lineMinY = min( line.start.y, line.end.y );
let lineMaxY = max( line.start.y, line.end.y );
let matrixWorld = shape.matrixWorld;
let side = ${ storage.transforms }[ shape.objectIndex ].side;
let inverted = determinant( matrixWorld ) < 0.0;
for ( var ti = offset; ti < offset + count; ti = ti + 1u ) {
let i0 = ${ storage.index }[ ti * 3u + 0u ];
let i1 = ${ storage.index }[ ti * 3u + 1u ];
let i2 = ${ storage.index }[ ti * 3u + 2u ];
let ta = matrixWorld * vec4f( ${ storage.attributes }[ i0 ].position.xyz, 1.0 );
let tb = matrixWorld * vec4f( ${ storage.attributes }[ i1 ].position.xyz, 1.0 );
let tc = matrixWorld * vec4f( ${ storage.attributes }[ i2 ].position.xyz, 1.0 );
tri.a = ta.xyz / ta.w;
tri.b = tb.xyz / tb.w;
tri.c = tc.xyz / tc.w;
// back-face cull
if ( side != ${ DOUBLE_SIDE } ) {
let triNormal = ${ TriWGSL.getNormal }( tri );
let faceUp = ( triNormal.y > 0.0 ) != inverted;
if ( faceUp == ( side == ${ BACK_SIDE } ) ) {
continue;
}
}
let triMaxY = max( max( tri.a.y, tri.b.y ), tri.c.y );
let triMinY = min( min( tri.a.y, tri.b.y ), tri.c.y );
// skip triangles entirely below the edge
if ( triMaxY <= lineMinY ) {
continue;
}
// skip if the edge lies on this triangle
if ( ${ isLineTriangleEdge }( tri, line ) ) {
continue;
}
// trim edge to the portion below the triangle plane; if the
// entire line is already below the triangle, use the full line
var beneathLine: ${ LineWGSL.struct };
if ( lineMaxY < triMinY ) {
beneathLine = line;
} else if ( ! ${ trimToBeneathTriPlane }( tri, line, &beneathLine ) ) {
continue;
}
// skip degenerate trimmed segments
// TODO: add a "distant" utility function
if ( length( beneathLine.end - beneathLine.start ) < ${ DIST_THRESHOLD } ) {
continue;
}
var overlapLine: ${ LineWGSL.struct };
if ( ! ${ getProjectedOverlapRange }( beneathLine, tri, &overlapLine ) ) {
continue;
}
// compute t0/t1 parametric positions along the original edge
let lineDir = line.end - line.start;
let lineLen = length( lineDir );
var t0 = length( overlapLine.start - line.start ) / lineLen;
var t1 = length( overlapLine.end - line.start ) / lineLen;
t0 = clamp( t0, 0.0, 1.0 );
t1 = clamp( t1, 0.0, 1.0 );
if ( abs( t0 - t1 ) <= ${ DIST_THRESHOLD } ) {
continue;
}
// claim a slot and write the overlap record directly
let slot = atomicAdd( &${ bufferPointersStorage }[ 0 ], 1u );
if ( slot < arrayLength( &${ overlapsStorage } ) ) {
${ overlapsStorage }[ slot ].edgeIndex = shape.edgeIndex;
${ overlapsStorage }[ slot ].t0 = t0;
${ overlapsStorage }[ slot ].t1 = t1;
} else {
atomicAdd( &${ overflowFlagStorage }[ 0 ], 1u );
}
}
return false;
}
`;
const traversalFn = this.getShapecastFn( {
name: 'collectEdgeOverlaps',
shapeStruct: edgeLineShapeStruct,
intersectsBoundsFn,
intersectRangeFn,
transformShapeFn,
} );
return wgslTagFn/* wgsl */`
fn traverse( edgeIndex: u32, lineStart: vec3f, lineEnd: vec3f ) -> void {
var shape: ${ edgeLineShapeStruct };
shape.worldStart = lineStart;
shape.worldEnd = lineEnd;
shape.matrixWorld = mat4x4f(
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0
);
shape.objectIndex = 0u;
shape.edgeIndex = edgeIndex;
${ traversalFn }( shape );
}
`;
}
}
================================================
FILE: src/webgpu/index.js
================================================
export * from './ProjectionGenerator.js';
export * from './MeshVisibilityCuller.js';
================================================
FILE: src/webgpu/kernels/EdgeOverlapsKernel.js
================================================
import { globalId, storage, uniform } from 'three/tsl';
import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js';
import { ComputeKernel } from '../utils/ComputeKernel.js';
import { proxyFn } from '../lib/nodes/NodeProxy.js';
import { StorageBufferAttribute } from 'three/webgpu';
import { edgeStruct } from '../nodes/structs.wgsl.js';
// One thread per edge — traverses the BVH and writes overlap intervals directly
// to the overlaps buffer via atomic slot claiming.
export class EdgeOverlapsKernel extends ComputeKernel {
constructor() {
const params = {
bvhData: { value: null },
globalId: globalId,
edgesToProcess: uniform( 1, 'uint' ),
edges: storage( new StorageBufferAttribute( 1, 1, Uint32Array ), edgeStruct ).toReadOnly().setName( 'edges' ),
};
const edges = params.edges;
const traversalFn = proxyFn( 'bvhData.value.fns.collectEdgeOverlaps', params );
const shader = wgslTagFn/* wgsl */`
fn compute( globalId: vec3u, edgesToProcess: u32 ) -> void {
let edgeIndex = globalId.x;
let edgeListLength = arrayLength( &${ edges } );
if ( edgeIndex >= edgeListLength || edgeIndex >= edgesToProcess ) {
return;
}
let edgeStart = vec3f(
${ edges }[ edgeIndex ].start[ 0 ],
${ edges }[ edgeIndex ].start[ 1 ],
${ edges }[ edgeIndex ].start[ 2 ]
);
let edgeEnd = vec3f(
${ edges }[ edgeIndex ].end[ 0 ],
${ edges }[ edgeIndex ].end[ 1 ],
${ edges }[ edgeIndex ].end[ 2 ]
);
${ traversalFn }( edgeIndex, edgeStart, edgeEnd );
}
`;
super( shader( params ) );
this.defineUniformAccessors( params );
}
}
================================================
FILE: src/webgpu/kernels/ZeroOutBufferKernel.js
================================================
import { IndirectStorageBufferAttribute } from 'three/webgpu';
import { storage, globalId } from 'three/tsl';
import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js';
import { ComputeKernel } from '../utils/ComputeKernel.js';
export class ZeroOutBufferKernel extends ComputeKernel {
constructor( options = {} ) {
const {
type = 'u32',
} = options;
const params = {
globalId: globalId,
outputTarget: storage( new IndirectStorageBufferAttribute( 1, 1 ), type ),
};
const fn = wgslTagFn/* wgsl */`
fn compute( globalId: vec3u ) -> void {
${ params.outputTarget }[ globalId.x ] = 0;
}
`;
super( fn( params ) );
this.defineUniformAccessors( {
target: params.outputTarget,
} );
}
}
================================================
FILE: src/webgpu/lib/BVHComputeData.js
================================================
import { Matrix4, SkinnedMesh, Vector4 } from 'three';
import { Mesh, StorageBufferAttribute, StructTypeNode } from 'three/webgpu';
import { storage, wgsl } from 'three/tsl';
import { constants } from './wgsl/common.wgsl.js';
import { rayStruct, bvhNodeStruct, bvhNodeBoundsStruct } from './wgsl/structs.wgsl.js';
import { wgslTagCode, wgslTagFn } from './nodes/WGSLTagFnNode.js';
import { MeshBVH, SkinnedMeshBVH, GeometryBVH, ObjectBVH, SAH } from 'three-mesh-bvh';
// TODO: add ability to easily update a single matrix / scene rearrangement (partial update)
// TODO: add material support w/ function to easily update material
// - add a callback for writing a property for a geometry to a range
// TODO: Add support for other geometry types (tris, lines, custom BVHs etc)
// temporary shim so StructTypeNodes can be passed to storage functions until
// this is fixed in three.js
Object.defineProperty( StructTypeNode.prototype, 'layout', {
get() {
return this;
}
} );
StructTypeNode.prototype.isStruct = true;
//
const isVisible = object => {
let curr = object;
while ( curr ) {
if ( curr.visible === false ) {
return false;
}
curr = curr.parent;
}
return true;
};
const applyBoneTransform = ( () => {
// a vec4-compatible version of SkinnedMesh.applyBoneTransform to support directions, positions
const _base = new Vector4();
const _skinIndex = new Vector4();
const _skinWeight = new Vector4();
const _matrix4 = new Matrix4();
const _vector4 = new Vector4();
return function applyBoneTransform( mesh, index, target ) {
const skeleton = mesh.skeleton;
const geometry = mesh.geometry;
_skinIndex.fromBufferAttribute( geometry.attributes.skinIndex, index );
_skinWeight.fromBufferAttribute( geometry.attributes.skinWeight, index );
if ( target.isVector4 ) {
_base.copy( target );
target.set( 0, 0, 0, 0 );
} else {
_base.set( ...target, 1 );
target.set( 0, 0, 0 );
}
_base.applyMatrix4( mesh.bindMatrix );
for ( let i = 0; i < 4; i ++ ) {
const weight = _skinWeight.getComponent( i );
if ( weight !== 0 ) {
const boneIndex = _skinIndex.getComponent( i );
_matrix4.multiplyMatrices( skeleton.bones[ boneIndex ].matrixWorld, skeleton.boneInverses[ boneIndex ] );
target.addScaledVector( _vector4.copy( _base ).applyMatrix4( _matrix4 ), weight );
}
}
if ( target.isVector4 ) {
target.w = _base.w;
}
return target.applyMatrix4( mesh.bindMatrixInverse );
};
} )();
//
// structs
const transformStruct = new StructTypeNode( {
matrixWorld: 'mat4x4f',
inverseMatrixWorld: 'mat4x4f',
nodeOffset: 'uint',
visible: 'uint',
_alignment0: 'uint',
_alignment1: 'uint',
}, 'TransformStruct' );
const intersectionResultStruct = new StructTypeNode( {
indices: 'vec4u',
normal: 'vec3f',
didHit: 'bool',
barycoord: 'vec3f',
objectIndex: 'uint',
side: 'float',
dist: 'float',
}, 'IntersectionResult' );
//
// node constants
const BYTES_PER_NODE = 6 * 4 + 4 + 4;
const UINT32_PER_NODE = BYTES_PER_NODE / 4;
const IS_LEAFNODE_FLAG = 0xFFFF;
// scratch
const _def = /* @__PURE__ */ new Vector4();
const _vec = /* @__PURE__ */ new Vector4();
const _matrix = /* @__PURE__ */ new Matrix4();
const _inverseMatrix = /* @__PURE__ */ new Matrix4();
// functions
function dereferenceIndex( indexAttr, indirectBuffer ) {
const indexArray = indexAttr ? indexAttr.array : null;
const result = new Uint32Array( indirectBuffer.length * 3 );
for ( let i = 0, l = indirectBuffer.length; i < l; i ++ ) {
const i3 = 3 * i;
const v3 = 3 * indirectBuffer[ i ];
for ( let c = 0; c < 3; c ++ ) {
result[ i3 + c ] = indexArray ? indexArray[ v3 + c ] : v3 + c;
}
}
return result;
}
function getTotalBVHByteLength( bvh ) {
return bvh._roots.reduce( ( v, root ) => v + root.byteLength, 0 );
}
const intersectsTriangle = wgslTagFn/* wgsl */ `
// fn
fn intersectsTriangle( ray: ${ rayStruct }, a: vec3f, b: vec3f, c: vec3f ) -> ${ intersectionResultStruct } {
// TODO: see if we can remove the "DIST" epsilon and account for it on ray origin bounce positioning
const DET_EPSILON = 1e-15;
const DIST_EPSILON = 1e-5;
var result: ${ intersectionResultStruct };
result.didHit = false;
let edge1 = b - a;
let edge2 = c - a;
let n = cross( edge1, edge2 );
let det = - dot( ray.direction, n );
if ( abs( det ) < DET_EPSILON ) {
return result;
}
let invdet = 1.0 / det;
let AO = ray.origin - a;
let DAO = cross( AO, ray.direction );
let u = dot( edge2, DAO ) * invdet;
if ( u < 0.0 || u > 1.0 ) {
return result;
}
let v = - dot( edge1, DAO ) * invdet;
if ( v < 0.0 || u + v > 1.0 ) {
return result;
}
let t = dot( AO, n ) * invdet;
let w = 1.0 - u - v;
if ( t < DIST_EPSILON ) {
return result;
}
result.didHit = true;
result.barycoord = vec3f( w, u, v );
result.dist = t;
result.side = sign( det );
result.normal = result.side * normalize( n );
return result;
}
`;
export class BVHComputeData {
constructor( bvh, options = {} ) {
// convert the bvh argument to an ObjectBVH. Supports the following as arguments
// - Object3D
// - BufferGeometry
// - GeometryBVH
// - Array of the above
if ( ! ( bvh instanceof ObjectBVH ) ) {
if ( ! Array.isArray( bvh ) ) {
bvh = [ bvh ];
}
const objects = bvh.map( item => {
if ( item.isObject3D ) {
return item;
} else if ( item.isBufferGeometry ) {
return new Mesh( item );
} else if ( item instanceof GeometryBVH ) {
const dummy = new Mesh();
dummy.geometry.boundsTree = item;
return dummy;
}
} );
bvh = new ObjectBVH( objects, { strategy: SAH, maxLeafSize: 1 } );
}
const {
attributes = { position: 'vec4f' },
autogenerateBvh = true,
} = options;
this._bvhCache = new Map();
this.autogenerateBvh = autogenerateBvh;
this.attributes = attributes;
this.bvh = bvh;
this.storage = {
index: null,
attributes: null,
nodes: null,
transforms: null,
};
this.structs = {
transform: transformStruct,
attributes: null,
};
this.fns = {
raycastFirstHit: null,
};
}
getShapecastFn( options ) {
const {
name = `bvh_${ Math.random().toString( 36 ).substring( 2, 7 ) }`,
shapeStruct,
resultStruct = null,
boundsOrderFn = null,
intersectsBoundsFn,
intersectRangeFn,
transformShapeFn = null,
transformResultFn = null,
} = options;
const { storage } = this;
const { BVH_STACK_DEPTH } = constants;
// handle optional functions
let transformResultSnippet = '';
if ( transformResultFn ) {
transformResultSnippet = wgslTagCode/* wgsl */`${ transformResultFn }( result, i );`;
}
let transformShapeSnippet = '';
if ( transformShapeFn ) {
transformShapeSnippet = wgslTagCode/* wgsl */`${ transformShapeFn }( &localShape, i );`;
}
let leftToRightSnippet = '';
if ( boundsOrderFn ) {
leftToRightSnippet = wgslTagCode/* wgsl */`
let leftToRight = ${ boundsOrderFn }( shape, splitAxis, node );
c1 = select( rightIndex, leftIndex, leftToRight );
c2 = select( leftIndex, rightIndex, leftToRight );
`;
}
const resultPtrSnippet = resultStruct ? wgslTagCode/* wgsl */`result: ptr<function, ${ resultStruct }>` : '';
const resultArg = resultStruct ? 'result' : '';
const getFnBody = leafSnippet => {
// returns a function with a snippet inserted for the leaf intersection test
return wgslTagCode/* wgsl */`
var pointer: i32 = 0;
var stack: array<u32, ${ BVH_STACK_DEPTH }>;
stack[ 0 ] = rootNodeIndex;
loop {
if ( pointer < 0 || pointer >= i32( ${ BVH_STACK_DEPTH } ) ) {
break;
}
let nodeIndex = stack[ pointer ];
let node = ${ storage.nodes }[ nodeIndex ];
pointer = pointer - 1;
if ( ${ intersectsBoundsFn }( shape, node.bounds, ${ resultArg } ) == 0u ) {
continue;
}
let infoX = node.splitAxisOrTriangleCount;
let infoY = node.rightChildOrTriangleOffset;
let isLeaf = ( infoX & 0xffff0000u ) != 0u;
if ( isLeaf ) {
let count = infoX & 0x0000ffffu;
let offset = infoY;
${ leafSnippet }
} else {
let leftIndex = nodeIndex + 1u;
let splitAxis = infoX & 0x0000ffffu;
let rightIndex = nodeIndex + infoY;
var c1 = rightIndex;
var c2 = leftIndex;
${ leftToRightSnippet }
pointer = pointer + 1;
stack[ pointer ] = c2;
pointer = pointer + 1;
stack[ pointer ] = c1;
}
}
`;
};
const blasFn = wgslTagFn/* wgsl */`
// fn
fn ${ name }_blas( shape: ${ shapeStruct }, rootNodeIndex: u32, ${ resultPtrSnippet } ) -> bool {
var didHit = false;
${ getFnBody( wgslTagCode/* wgsl */`
didHit = ${ intersectRangeFn }( shape, offset, count, ${ resultArg } ) || didHit;
` ) }
return didHit;
}
`;
const tlasFn = wgslTagFn/* wgsl */`
// fn
fn ${ name }( shape: ${ shapeStruct }, ${ resultPtrSnippet } ) -> bool {
const rootNodeIndex = 0u;
var didHit = false;
${ getFnBody( wgslTagCode/* wgsl */`
for ( var i = offset; i < offset + count; i ++ ) {
let transform = ${ storage.transforms }[ i ];
if ( transform.visible == 0u ) {
continue;
}
// Transform shape into object local space
var localShape = shape;
${ transformShapeSnippet }
if ( ${ blasFn }( localShape, transform.nodeOffset, ${ resultArg } ) ) {
${ transformResultSnippet }
didHit = true;
}
}
` ) }
return didHit;
}
`;
tlasFn.outputType = resultStruct;
tlasFn.functionName = name;
return tlasFn;
}
update() {
const self = this;
const { attributes, structs, bvh } = this;
// collect the BVHs
const bvhInfo = [];
const transformInfo = [];
// accumulate the sizes of the bvh nodes buffer, number of objects, and geometry buffers
let bvhNodesBufferLength = getTotalBVHByteLength( bvh );
let indexBufferLength = 0;
let attributesBufferLength = 0;
bvh.primitiveBuffer.forEach( compositeId => {
const object = bvh.getObjectFromId( compositeId );
const instanceId = bvh.getInstanceFromId( compositeId );
const range = { start: 0, count: 0, vertexStart: 0, vertexCount: 0 };
const primBvh = this.getBVH( object, instanceId, range );
if ( ! primBvh ) {
throw new Error( 'BVHComputeData: BVH not found.' );
}
// if we haven't added this bvh, yet
if ( ! bvhInfo.find( info => info.bvh === primBvh ) ) {
// save the geometry info to write later and increment the buffer sizes
const info = {
index: bvhInfo.length,
bvh: primBvh,
range: range,
bvhNodeOffsets: null,
indexBufferOffset: null,
};
// increase the buffer sizes for bvh and geometry
bvhNodesBufferLength += getTotalBVHByteLength( primBvh );
indexBufferLength += info.range.count;
attributesBufferLength += info.range.vertexCount;
bvhInfo.push( info );
}
// save the index of the bvh associated with this transform
const data = bvhInfo.find( info => primBvh === info.bvh );
primBvh._roots.forEach( ( root, i ) => {
transformInfo.push( {
data,
root: i,
object,
instanceId,
compositeId,
} );
} );
} );
//
// NOTE: These buffer lengths are increased to a minimum size of 2 to avoid the TSL of converting storage buffers
// with length 1 being converted to a scalar value.
// TODO: remove this when fixed in three
const transformBufferLength = Math.max( transformInfo.length, 2 );
indexBufferLength = Math.max( indexBufferLength, 2 );
attributesBufferLength = Math.max( attributesBufferLength, 2 );
// construct the attribute struct
const attributeStruct = new StructTypeNode( attributes, 'bvh_GeometryStruct' );
// write the geometry buffer attributes & bvh data
let attributesOffset = 0;
let indexOffset = 0;
let nodeWriteOffset = 0;
const indexBuffer = new Uint32Array( indexBufferLength );
const attributesBuffer = new ArrayBuffer( attributesBufferLength * attributeStruct.getLength() * 4 );
const bvhNodesBuffer = new ArrayBuffer( bvhNodesBufferLength );
// append TLAS data
appendBVHData( bvh, 0, transformInfo, 0, bvhNodesBuffer, true );
nodeWriteOffset += getTotalBVHByteLength( bvh ) / BYTES_PER_NODE;
bvhInfo.forEach( info => {
// append bvh data
const bvhNodeOffsets = appendBVHData( info.bvh, indexOffset / 3, transformInfo, nodeWriteOffset, bvhNodesBuffer, false );
info.bvhNodeOffsets = bvhNodeOffsets;
// append geometry data
appendIndexData( info.bvh, info.range, attributesOffset, indexOffset, indexBuffer );
appendGeometryData( info.bvh, info.range, attributesOffset, attributesBuffer );
info.indexBufferOffset = indexOffset;
// step the write offsets forward
indexOffset += info.range.count;
attributesOffset += info.range.vertexCount;
nodeWriteOffset += getTotalBVHByteLength( info.bvh ) / BYTES_PER_NODE;
} );
//
// write the transforms
const transformArrayBuffer = new ArrayBuffer( structs.transform.getLength() * transformBufferLength * 4 );
transformInfo.forEach( ( info, i ) => {
_inverseMatrix.copy( bvh.matrixWorld ).invert();
this.writeTransformData( info, _inverseMatrix, i, transformArrayBuffer );
} );
//
// set up the storage buffers
// if itemSize for StorageBufferAttribute == arraySize,
// then buffer is treated not as array of structs, but as a single struct
// And that breaks code. For now itemSize = 1 does not seem to break anything
const bvhNodesStorage = storage( new StorageBufferAttribute( new Uint32Array( bvhNodesBuffer ), 1 ), bvhNodeStruct ).toReadOnly().setName( 'bvh_nodes' );
const transformsBuffer = new StorageBufferAttribute( new Uint32Array( transformArrayBuffer ), 1 );
const transformsStorage = storage( transformsBuffer, structs.transform ).toReadOnly().setName( 'bvh_transforms' );
const indexStorage = storage( new StorageBufferAttribute( indexBuffer, 1 ), 'uint' ).toReadOnly().setName( 'bvh_index' );
const attributesStorage = storage( new StorageBufferAttribute( new Uint32Array( attributesBuffer ), attributeStruct.getLength() ), attributeStruct ).toReadOnly().setName( 'bvh_attributes' );
this.storage.transforms = transformsStorage;
this.storage.nodes = bvhNodesStorage;
this.storage.index = indexStorage;
this.storage.attributes = attributesStorage;
this.structs.attributes = attributeStruct;
this._initFns();
this._bvhCache.clear();
function appendBVHData( bvh, geometryOffset, transformInfo, nodeWriteOffset, target, tlas = false ) {
const targetU16 = new Uint16Array( target );
const targetU32 = new Uint32Array( target );
const targetF32 = new Float32Array( target );
const result = [];
let tlasOffset = 0;
bvh._roots.forEach( root => {
const rootBuffer16 = new Uint16Array( root );
const rootBuffer32 = new Uint32Array( root );
result.push( nodeWriteOffset );
for ( let i = 0, l = root.byteLength / BYTES_PER_NODE; i < l; i ++ ) {
const r32 = i * UINT32_PER_NODE;
const r16 = r32 * 2;
const n32 = nodeWriteOffset * UINT32_PER_NODE;
const n16 = n32 * 2;
// write bounds
const view = new Float32Array( root, i * BYTES_PER_NODE, 6 );
if ( i === 0 ) {
// if we're copying the root then check for cases where there are no primitives and therefore
// be a bounds of [ Infinity, - Infinity ]. Convert this to [ 1, - 1 ] for reliable GPU behavior.
for ( let i = 0; i < 3; i ++ ) {
const vMin = view[ i + 0 ];
const vMax = view[ i + 3 ];
if ( vMin > vMax ) {
targetF32[ n32 + i + 0 ] = 1;
targetF32[ n32 + i + 3 ] = - 1;
} else {
targetF32[ n32 + i + 0 ] = vMin;
targetF32[ n32 + i + 3 ] = vMax;
}
}
} else {
targetF32.set( view, n32 );
}
const isLeaf = IS_LEAFNODE_FLAG === rootBuffer16[ r16 + 15 ];
if ( isLeaf ) {
if ( tlas ) {
// 0xFFFF == mesh leaf, 0xFF00 == TLAS leaf
targetU32[ n32 + 6 ] = tlasOffset;
targetU16[ n16 + 15 ] = 0xFF00;
const count = rootBuffer16[ r16 + 14 ];
// const offset = rootBuffer32[ r32 + 6 ];
// each root is expanded into a separate transform so we need to expand
// the embedded offsets and counts.
let rootsCount = 0;
for ( let o = 0; o < count; o ++ ) {
const roots = transformInfo[ tlasOffset ].data.bvh._roots.length;
tlasOffset += roots;
rootsCount += roots;
}
targetU16[ n16 + 14 ] = rootsCount;
} else {
targetU32[ n32 + 6 ] = rootBuffer32[ r32 + 6 ] + geometryOffset;
targetU16[ n16 + 14 ] = rootBuffer16[ r16 + 14 ];
targetU16[ n16 + 15 ] = IS_LEAFNODE_FLAG;
}
} else {
targetU32[ n32 + 6 ] = rootBuffer32[ r32 + 6 ];
targetU32[ n32 + 7 ] = rootBuffer32[ r32 + 7 ];
}
nodeWriteOffset ++;
}
} );
return result;
}
function appendIndexData( bvh, range, valueOffset, writeOffset, target ) {
const { geometry } = bvh;
const { start, count, vertexStart } = range;
if ( bvh.indirect ) {
const dereferencedIndex = dereferenceIndex( geometry.index, bvh._indirectBuffer );
for ( let i = 0; i < dereferencedIndex.length; i ++ ) {
target[ i + writeOffset ] = dereferencedIndex[ i ] - vertexStart + valueOffset;
}
} else if ( geometry.index ) {
for ( let i = 0; i < count; i ++ ) {
target[ i + writeOffset ] = geometry.index.getX( i + start ) - vertexStart + valueOffset;
}
} else {
for ( let i = 0; i < count; i ++ ) {
target[ i + writeOffset ] = i + start + valueOffset;
}
}
}
function appendGeometryData( bvh, range, writeOffset, target ) {
// if "mesh" is present then it is assumed to be a SkinnedMeshBVH
const { geometry, mesh = null } = bvh;
const { vertexStart, vertexCount } = range;
const attributesBufferF32 = new Float32Array( target );
const attrStructLength = attributeStruct.getLength();
attributeStruct.membersLayout.forEach( ( { name }, interleavedOffset ) => {
// TODO: we should be able to have access to memory layout offsets here via the struct
// API but it's not currently available.
const attr = geometry.attributes[ name ];
self.getDefaultAttributeValue( name, _def );
for ( let i = 0; i < vertexCount; i ++ ) {
if ( attr ) {
_vec.fromBufferAttribute( attr, i + vertexStart );
switch ( attr.itemSize ) {
case 1:
_vec.y = _def.y;
_vec.z = _def.z;
_vec.w = _def.w;
break;
case 2:
_vec.z = _def.z;
_vec.w = _def.w;
break;
case 3:
_vec.w = _def.w;
break;
}
if ( mesh && ( name === 'position' || name === 'normal' || name === 'tangent' ) ) {
applyBoneTransform( mesh, i + vertexStart, _vec );
}
} else {
_vec.copy( _def );
}
_vec.toArray( attributesBufferF32, ( writeOffset + i ) * attrStructLength + interleavedOffset * 4 );
}
} );
}
}
_initFns() {
const { storage, structs, fns } = this;
// raycast first hit
const scratchRayScalar = wgsl( /* wgsl */`
var<private> bvh_rayScalar = 1.0;
` );
fns.raycastFirstHit = this.getShapecastFn( {
name: 'bvh_RaycastFirstHit',
shapeStruct: rayStruct,
resultStruct: intersectionResultStruct,
boundsOrderFn: wgslTagFn/* wgsl */`
fn getBoundsOrder( ray: ${ rayStruct }, splitAxis: u32, node: ${ bvhNodeStruct } ) -> bool {
return ray.direction[ splitAxis ] >= 0.0;
}
`,
intersectsBoundsFn: wgslTagFn/* wgsl */`
${ [ scratchRayScalar ] }
fn rayIntersectsBounds( ray: ${ rayStruct }, bounds: ${ bvhNodeBoundsStruct }, result: ptr<function, ${ intersectionResultStruct }> ) -> u32 {
let boundsMin = vec3( bounds.min[0], bounds.min[1], bounds.min[2] );
let boundsMax = vec3( bounds.max[0], bounds.max[1], bounds.max[2] );
let invDir = 1.0 / ray.direction;
let tMinPlane = ( boundsMin - ray.origin ) * invDir;
let tMaxPlane = ( boundsMax - ray.origin ) * invDir;
let tMinHit = vec3f(
min( tMinPlane.x, tMaxPlane.x ),
min( tMinPlane.y, tMaxPlane.y ),
min( tMinPlane.z, tMaxPlane.z )
);
let tMaxHit = vec3f(
max( tMinPlane.x, tMaxPlane.x ),
max( tMinPlane.y, tMaxPlane.y ),
max( tMinPlane.z, tMaxPlane.z )
);
let t0 = max( max( tMinHit.x, tMinHit.y ), tMinHit.z );
let t1 = min( min( tMaxHit.x, tMaxHit.y ), tMaxHit.z );
let dist = max( t0, 0.0 );
if ( t1 < dist ) {
return 0u;
} else if ( result.didHit && dist * bvh_rayScalar >= result.dist ) {
return 0u;
} else {
return 1u;
}
}
`,
intersectRangeFn: wgslTagFn/* wgsl */`
${ [ scratchRayScalar ] }
fn intersectRange( ray: ${ rayStruct }, offset: u32, count: u32, result: ptr<function, ${ intersectionResultStruct }> ) -> bool {
var didHit = false;
for ( var ti = offset; ti < offset + count; ti = ti + 1u ) {
let i0 = ${ storage.index }[ ti * 3u ];
let i1 = ${ storage.index }[ ti * 3u + 1u ];
let i2 = ${ storage.index }[ ti * 3u + 2u ];
let a = ${ storage.attributes }[ i0 ].position.xyz;
let b = ${ storage.attributes }[ i1 ].position.xyz;
let c = ${ storage.attributes }[ i2 ].position.xyz;
var triResult = ${ intersectsTriangle }( ray, a, b, c );
triResult.dist *= bvh_rayScalar;
if ( triResult.didHit && ( ! result.didHit || triResult.dist < result.dist ) ) {
result.didHit = true;
result.dist = triResult.dist;
result.normal = triResult.normal;
result.side = triResult.side;
result.barycoord = triResult.barycoord;
result.indices = vec4u( i0, i1, i2, ti );
didHit = true;
}
}
return didHit;
}
`,
transformShapeFn: wgslTagFn/* wgsl */`
${ [ scratchRayScalar ] }
fn transformRay( ray: ptr<function, ${ rayStruct }>, objectIndex: u32 ) -> void {
let toLocal = ${ storage.transforms }[ objectIndex ].inverseMatrixWorld;
ray.origin = ( toLocal * vec4f( ray.origin, 1.0 ) ).xyz;
ray.direction = ( toLocal * vec4f( ray.direction, 0.0 ) ).xyz;
let len = length( ray.direction );
ray.direction /= len;
bvh_rayScalar = 1.0 / len;
}
`,
transformResultFn: wgslTagFn/* wgsl */`
fn transformResult( hit: ptr<function, ${ intersectionResultStruct }>, objectIndex: u32 ) -> void {
let toLocal = ${ storage.transforms }[ objectIndex ].inverseMatrixWorld;
hit.normal = normalize( ( transpose( toLocal ) * vec4f( hit.normal, 0.0 ) ).xyz );
hit.objectIndex = objectIndex;
}
`,
} );
const interpolateBody = structs
.attributes
.membersLayout
.map( ( { name } ) => {
return `result.${ name } = a0.${ name } * barycoord.x + a1.${ name } * barycoord.y + a2.${ name } * barycoord.z;`;
} ).join( '\n' );
fns.sampleTrianglePoint = wgslTagFn/* wgsl */`
// fn
fn bvh_sampleTrianglePoint( barycoord: vec3f, indices: vec3u ) -> ${ structs.attributes } {
var result: ${ structs.attributes };
var a0 = ${ storage.attributes }[ indices.x ];
var a1 = ${ storage.attributes }[ indices.y ];
var a2 = ${ storage.attributes }[ indices.z ];
${ interpolateBody }
return result;
}
`;
}
writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer ) {
const { structs } = this;
const transformBufferF32 = new Float32Array( targetBuffer );
const transformBufferU32 = new Uint32Array( targetBuffer );
const { object, instanceId, root, data } = info;
const { bvhNodeOffsets } = data;
if ( object.isInstancedMesh || object.isBatchedMesh ) {
object.getMatrixAt( instanceId, _matrix );
_matrix.premultiply( object.matrixWorld );
} else {
_matrix.copy( object.matrixWorld );
}
// write transform
_matrix.premultiply( premultiplyMatrix );
_matrix.toArray( transformBufferF32, writeOffset * structs.transform.getLength() );
// write inverse transform
_matrix.invert();
_matrix.toArray( transformBufferF32, writeOffset * structs.transform.getLength() + 16 );
// write node offset
transformBufferU32[ writeOffset * structs.transform.getLength() + 32 ] = bvhNodeOffsets[ root ];
let visible = isVisible( object );
if ( object.isBatchedMesh ) {
visible = visible && object.getVisibleAt( instanceId );
}
transformBufferU32[ writeOffset * structs.transform.getLength() + 33 ] = visible ? 1 : 0;
}
getBVH( object, instanceId, rangeTarget ) {
const { autogenerateBvh, _bvhCache } = this;
let bvh = null;
if ( object.boundsTree || object.isSkinnedMesh ) {
// this is a case where a mesh has morph targets and skinned meshes
const geometry = object.geometry;
rangeTarget.count = geometry.index ? geometry.index.count : geometry.attributes.position.count;
rangeTarget.vertexCount = geometry.attributes.position.count;
bvh = object.boundsTree || null;
if ( bvh === null && autogenerateBvh ) {
const id = object.uuid;
bvh = _bvhCache.get( id ) || new SkinnedMeshBVH( object );
_bvhCache.set( id, bvh );
}
} else if ( object.isBatchedMesh ) {
const geometryId = object.getGeometryIdAt( instanceId );
const range = object.getGeometryRangeAt( geometryId );
Object.assign( rangeTarget, range );
bvh = object.boundsTrees[ geometryId ] || null;
if ( bvh === null && autogenerateBvh ) {
const id = `batched_${ object.geometry.uuid }_${ range.start }_${ range.count }`;
bvh = _bvhCache.get( id ) || new MeshBVH( object.geometry, { range: { ...rangeTarget } } );
_bvhCache.set( id, bvh );
}
} else {
const geometry = object.geometry;
rangeTarget.count = geometry.index ? geometry.index.count : geometry.attributes.position.count;
rangeTarget.vertexCount = geometry.attributes.position.count;
bvh = object.geometry.boundsTree || null;
if ( bvh === null && autogenerateBvh ) {
const id = geometry.uuid;
bvh = _bvhCache.get( id ) || new MeshBVH( geometry );
_bvhCache.set( id, bvh );
}
}
return bvh;
}
getDefaultAttributeValue( key, target ) {
switch ( key ) {
case 'position':
case 'color':
target.set( 1, 1, 1, 1 );
break;
default:
target.set( 0, 0, 0, 0 );
}
return target;
}
dispose() {
// TODO: dispose buffers
}
}
================================================
FILE: src/webgpu/lib/nodes/NodeProxy.js
============================================
gitextract__n5tqol9/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── examples-build.yml │ └── node.js.yml ├── .gitignore ├── API.md ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── example/ │ ├── bimProjection.html │ ├── bimProjection.js │ ├── edgeProjection.html │ ├── edgeProjection.js │ ├── edgeProjectionWebGPU.html │ ├── edgeProjectionWebGPU.js │ ├── floorProjection.html │ ├── floorProjection.js │ ├── perspectiveProjection.html │ ├── perspectiveProjection.js │ ├── planarIntersection.html │ ├── planarIntersection.js │ ├── silhouetteProjection.html │ └── silhouetteProjection.js ├── package.json ├── src/ │ ├── EdgeGenerator.js │ ├── MeshVisibilityCuller.js │ ├── PlanarIntersectionGenerator.js │ ├── ProjectionGenerator.js │ ├── SilhouetteGenerator.js │ ├── index.js │ ├── utils/ │ │ ├── LineObjectsBVH.js │ │ ├── ProjectionEdge.js │ │ ├── bvhcastEdges.js │ │ ├── compressPoints.js │ │ ├── generateEdges.js │ │ ├── generateIntersectionEdges.js │ │ ├── geometryUtils.js │ │ ├── getAllMeshes.js │ │ ├── getProjectedLineOverlap.js │ │ ├── getProjectedOverlaps.js │ │ ├── getSizeSortedTriList.js │ │ ├── nextFrame.js │ │ ├── overlapUtils.js │ │ ├── planeUtils.js │ │ ├── triangleIsInsidePaths.js │ │ ├── triangleLineUtils.js │ │ └── trimToBeneathTriPlane.js │ ├── webgpu/ │ │ ├── MeshVisibilityCuller.js │ │ ├── ProjectionGenerator.js │ │ ├── ProjectionGeneratorBVHComputeData.js │ │ ├── index.js │ │ ├── kernels/ │ │ │ ├── EdgeOverlapsKernel.js │ │ │ └── ZeroOutBufferKernel.js │ │ ├── lib/ │ │ │ ├── BVHComputeData.js │ │ │ ├── nodes/ │ │ │ │ ├── NodeProxy.js │ │ │ │ └── WGSLTagFnNode.js │ │ │ └── wgsl/ │ │ │ ├── common.wgsl.js │ │ │ └── structs.wgsl.js │ │ ├── nodes/ │ │ │ ├── common.wgsl.js │ │ │ ├── overlapFunctions.wgsl.js │ │ │ ├── primitives.js │ │ │ ├── structs.wgsl.js │ │ │ └── utils.wgsl.js │ │ └── utils/ │ │ └── ComputeKernel.js │ └── worker/ │ ├── SilhouetteGeneratorWorker.js │ └── silhouetteAsync.worker.js ├── test/ │ ├── Utils.getProjectedLineOverlap.test.js │ ├── Utils.triangleLineUtils.test.js │ └── Utils.trimToBeneathTriPlane.test.js ├── utils/ │ ├── CommandUtils.js │ └── docs/ │ ├── RenderDocsUtils.js │ └── build.js ├── vite.config.js └── vitest.config.js
SYMBOL INDEX (229 symbols across 42 files)
FILE: example/bimProjection.js
constant ANGLE_THRESHOLD (line 51) | const ANGLE_THRESHOLD = 50;
function loadModel (line 258) | async function loadModel( url ) {
function generateClippingEdges (line 281) | function generateClippingEdges() {
function applyClipping (line 346) | function applyClipping() {
function updateEdges (line 476) | async function updateEdges() {
FILE: example/edgeProjection.js
constant ANGLE_THRESHOLD (line 51) | const ANGLE_THRESHOLD = 50;
function init (line 60) | async function init() {
function render (line 267) | function render() {
FILE: example/edgeProjectionWebGPU.js
function init (line 57) | async function init() {
function updateEdges (line 148) | async function updateEdges() {
function applyPerObjectColors (line 227) | function applyPerObjectColors( edgeSet, geometry, lightness = 0.5 ) {
function render (line 255) | function render() {
FILE: example/floorProjection.js
constant ANGLE_THRESHOLD (line 23) | const ANGLE_THRESHOLD = 50;
function init (line 40) | async function init() {
function render (line 218) | function render() {
FILE: example/perspectiveProjection.js
function init (line 41) | async function init() {
function updateEdges (line 132) | async function updateEdges() {
function render (line 249) | function render() {
FILE: example/planarIntersection.js
function init (line 36) | async function init() {
function updateLines (line 140) | function updateLines() {
function render (line 156) | function render() {
FILE: example/silhouetteProjection.js
function init (line 56) | async function init() {
function render (line 268) | function render() {
FILE: src/EdgeGenerator.js
class EdgeGenerator (line 15) | class EdgeGenerator {
method constructor (line 17) | constructor() {
method getEdges (line 27) | getEdges( ...args ) {
method getEdgesAsync (line 39) | async getEdgesAsync( ...args ) {
method getEdgesGenerator (line 54) | *getEdgesGenerator( geometry, resultEdges = [] ) {
method getIntersectionEdges (line 117) | getIntersectionEdges( ...args ) {
method getIntersectionEdgesAsync (line 129) | async getIntersectionEdgesAsync( ...args ) {
method getIntersectionEdgesGenerator (line 144) | *getIntersectionEdgesGenerator( geometry, resultEdges = [] ) {
function transformEdges (line 252) | function transformEdges( list, matrix, offset = 0 ) {
FILE: src/MeshVisibilityCuller.js
function encodeId (line 18) | function encodeId( id, target ) {
function decodeId (line 27) | function decodeId( buffer, index ) {
class MeshVisibilityCuller (line 45) | class MeshVisibilityCuller {
method constructor (line 47) | constructor( renderer, options = {} ) {
method cull (line 66) | async cull( objects ) {
class IDMaterial (line 179) | class IDMaterial extends ShaderMaterial {
method objectId (line 181) | set objectId( v ) {
method constructor (line 187) | constructor( params ) {
FILE: src/PlanarIntersectionGenerator.js
constant EPS (line 7) | const EPS = 1e-16;
class PlanarIntersectionGenerator (line 12) | class PlanarIntersectionGenerator {
method constructor (line 14) | constructor() {
method generate (line 29) | generate( bvh ) {
FILE: src/ProjectionGenerator.js
constant UP_VECTOR (line 17) | const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
function toLineGeometry (line 19) | function toLineGeometry( edges, ranges = null ) {
class EdgeSet (line 60) | class EdgeSet {
method constructor (line 62) | constructor() {
method getLineGeometry (line 77) | getLineGeometry( meshes = null ) {
method getRangeForMesh (line 106) | getRangeForMesh( mesh ) {
class ProjectionResult (line 130) | class ProjectionResult {
method constructor (line 132) | constructor() {
class ProjectedEdgeCollector (line 144) | class ProjectedEdgeCollector {
method constructor (line 146) | constructor( scene ) {
method addEdges (line 155) | addEdges( ...args ) {
method addEdgesGenerator (line 168) | *addEdgesGenerator( edges, options = {} ) {
class ProjectionGenerator (line 266) | class ProjectionGenerator {
method constructor (line 268) | constructor() {
method generateAsync (line 299) | async generateAsync( geometry, options = {} ) {
method generate (line 325) | *generate( scene, options = {} ) {
FILE: src/SilhouetteGenerator.js
constant AREA_EPSILON (line 8) | const AREA_EPSILON = 1e-8;
constant UP_VECTOR (line 9) | const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
function convertPathToGeometry (line 15) | function convertPathToGeometry( path, scale ) {
function convertPathToLineSegments (line 44) | function convertPathToLineSegments( path, scale ) {
constant OUTPUT_MESH (line 70) | const OUTPUT_MESH = 0;
constant OUTPUT_LINE_SEGMENTS (line 73) | const OUTPUT_LINE_SEGMENTS = 1;
constant OUTPUT_BOTH (line 76) | const OUTPUT_BOTH = 2;
class SilhouetteGenerator (line 87) | class SilhouetteGenerator {
method constructor (line 89) | constructor() {
method generateAsync (line 131) | generateAsync( geometry, options = {} ) {
method generate (line 174) | *generate( geometry, options = {} ) {
FILE: src/utils/LineObjectsBVH.js
class LineObjectsBVH (line 2) | class LineObjectsBVH extends BVH {
method lines (line 4) | get lines() {
method constructor (line 10) | constructor( lines, options ) {
method writePrimitiveBounds (line 22) | writePrimitiveBounds( i, targetBuffer, writeOffset ) {
method getRootRanges (line 37) | getRootRanges() {
FILE: src/utils/ProjectionEdge.js
class ProjectionEdge (line 3) | class ProjectionEdge extends Line3 {
method constructor (line 5) | constructor( start, end ) {
method copy (line 12) | copy( source ) {
FILE: src/utils/bvhcastEdges.js
constant UP_VECTOR (line 8) | const UP_VECTOR = new Vector3( 0, 1, 0 );
constant DIST_THRESHOLD (line 9) | const DIST_THRESHOLD = 1e-10;
function bvhcastEdges (line 20) | function bvhcastEdges( edgesBvh, bvh, mesh, hiddenOverlapMap ) {
FILE: src/utils/compressPoints.js
constant DIRECTION_EPSILON (line 1) | const DIRECTION_EPSILON = 1e-3;
constant DIST_EPSILON (line 2) | const DIST_EPSILON = 1e2;
function sameDirection (line 4) | function sameDirection( p0, p1, p2 ) {
function areClose (line 19) | function areClose( p0, p1 ) {
function areEqual (line 27) | function areEqual( p0, p1 ) {
function compressPoints (line 33) | function compressPoints( points ) {
FILE: src/utils/generateEdges.js
constant EPSILON (line 5) | const EPSILON = 1e-10;
constant UP_VECTOR (line 6) | const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
FILE: src/utils/generateIntersectionEdges.js
function generateIntersectionEdges (line 8) | function generateIntersectionEdges( bvhA, bvhB, matrixBToA, target = [] ) {
function areVectorsEqual (line 55) | function areVectorsEqual( a, b ) {
function areTrianglesOnEdge (line 61) | function areTrianglesOnEdge( t1, t2 ) {
FILE: src/utils/geometryUtils.js
function getTriCount (line 1) | function getTriCount( geometry ) {
FILE: src/utils/getAllMeshes.js
function getAllMeshes (line 1) | function getAllMeshes( scene ) {
FILE: src/utils/getProjectedLineOverlap.js
constant AREA_EPSILON (line 4) | const AREA_EPSILON = 1e-16;
constant DIST_EPSILON (line 5) | const DIST_EPSILON = 1e-16;
function getProjectedLineOverlap (line 19) | function getProjectedLineOverlap( line, triangle, lineTarget = new Line3...
FILE: src/utils/getProjectedOverlaps.js
constant DIST_EPSILON (line 2) | const DIST_EPSILON = 1e-16;
function appendOverlapRange (line 6) | function appendOverlapRange( line, overlapLine, overlapsTarget ) {
function getOverlapRange (line 21) | function getOverlapRange( line, overlapLine ) {
function insertOverlap (line 44) | function insertOverlap( result, overlapsTarget ) {
FILE: src/utils/getSizeSortedTriList.js
function getSizeSortedTriList (line 5) | function getSizeSortedTriList( geometry ) {
FILE: src/utils/overlapUtils.js
function overlapsToLines (line 6) | function overlapsToLines( line, overlaps, invert = false, target = [] ) {
FILE: src/utils/planeUtils.js
function getPlaneYAtPoint (line 8) | function getPlaneYAtPoint( plane, point, target = null ) {
function isLineAbovePlane (line 21) | function isLineAbovePlane( plane, line ) {
FILE: src/utils/triangleIsInsidePaths.js
function xzToXzCopy (line 3) | function xzToXzCopy( v, target ) {
function epsEquals (line 10) | function epsEquals( a, b ) {
function vectorEpsEquals (line 16) | function vectorEpsEquals( v0, v1 ) {
function triangleIsInsidePaths (line 24) | function triangleIsInsidePaths( tri, paths ) {
function lineCrossesLine (line 98) | function lineCrossesLine( l1, l2 ) {
FILE: src/utils/triangleLineUtils.js
constant EPSILON (line 3) | const EPSILON = 1e-16;
constant UP_VECTOR (line 4) | const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
function isYProjectedLineDegenerate (line 7) | function isYProjectedLineDegenerate( line ) {
function isYProjectedTriangleDegenerate (line 15) | function isYProjectedTriangleDegenerate( tri ) {
function isLineTriangleEdge (line 28) | function isLineTriangleEdge( tri, line ) {
FILE: src/utils/trimToBeneathTriPlane.js
constant EPSILON (line 3) | const EPSILON = 1e-16;
constant UP_VECTOR (line 4) | const UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );
function trimToBeneathTriPlane (line 8) | function trimToBeneathTriPlane( tri, line, lineTarget ) {
FILE: src/webgpu/MeshVisibilityCuller.js
function encodeId (line 17) | function encodeId( id, target ) {
function decodeId (line 26) | function decodeId( buffer, index ) {
class MeshVisibilityCuller (line 42) | class MeshVisibilityCuller {
method constructor (line 44) | constructor( renderer, options = {} ) {
method cull (line 63) | async cull( objects ) {
FILE: src/webgpu/ProjectionGenerator.js
constant MAX_BUFFER_SIZE (line 19) | const MAX_BUFFER_SIZE = 134217728;
constant MAX_OVERLAPS_COUNT (line 21) | const MAX_OVERLAPS_COUNT = Math.floor( MAX_BUFFER_SIZE / ( overlapRecord...
class ProjectionGenerator (line 33) | class ProjectionGenerator {
method constructor (line 35) | constructor( renderer ) {
method generate (line 86) | async generate( scene, options = {} ) {
class JobQueue (line 362) | class JobQueue {
method constructor (line 364) | constructor() {
method add (line 373) | add( cb, args ) {
method cancelAll (line 396) | cancelAll() {
method runJobs (line 408) | async runJobs() {
method scheduleRun (line 436) | scheduleRun() {
FILE: src/webgpu/ProjectionGeneratorBVHComputeData.js
class ProjectionGeneratorBVHComputeData (line 35) | class ProjectionGeneratorBVHComputeData extends BVHComputeData {
method constructor (line 37) | constructor( bvh, options = {} ) {
method writeTransformData (line 51) | writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer...
method update (line 83) | update() {
method getCollectEdgeOverlapsFn (line 105) | getCollectEdgeOverlapsFn( { overlapsStorage, bufferPointersStorage, ov...
FILE: src/webgpu/kernels/EdgeOverlapsKernel.js
class EdgeOverlapsKernel (line 10) | class EdgeOverlapsKernel extends ComputeKernel {
method constructor (line 12) | constructor() {
FILE: src/webgpu/kernels/ZeroOutBufferKernel.js
class ZeroOutBufferKernel (line 6) | class ZeroOutBufferKernel extends ComputeKernel {
method constructor (line 8) | constructor( options = {} ) {
FILE: src/webgpu/lib/BVHComputeData.js
method get (line 18) | get() {
constant BYTES_PER_NODE (line 132) | const BYTES_PER_NODE = 6 * 4 + 4 + 4;
constant UINT32_PER_NODE (line 133) | const UINT32_PER_NODE = BYTES_PER_NODE / 4;
constant IS_LEAFNODE_FLAG (line 134) | const IS_LEAFNODE_FLAG = 0xFFFF;
function dereferenceIndex (line 143) | function dereferenceIndex( indexAttr, indirectBuffer ) {
function getTotalBVHByteLength (line 163) | function getTotalBVHByteLength( bvh ) {
class BVHComputeData (line 229) | class BVHComputeData {
method constructor (line 231) | constructor( bvh, options = {} ) {
method getShapecastFn (line 299) | getShapecastFn( options ) {
method update (line 466) | update() {
method _initFns (line 795) | _initFns() {
method writeTransformData (line 947) | writeTransformData( info, premultiplyMatrix, writeOffset, targetBuffer...
method getBVH (line 988) | getBVH( object, instanceId, rangeTarget ) {
method getDefaultAttributeValue (line 1045) | getDefaultAttributeValue( key, target ) {
method dispose (line 1063) | dispose() {
FILE: src/webgpu/lib/nodes/NodeProxy.js
class ProxyCallNode (line 3) | class ProxyCallNode extends Node {
method type (line 5) | static get type() {
method constructor (line 11) | constructor( proxyNode, params ) {
method setup (line 19) | setup() {
class NodeProxy (line 27) | class NodeProxy {
method isNode (line 29) | get isNode() {
method proxyNode (line 36) | get proxyNode() {
method constructor (line 59) | constructor( property, object = null ) {
FILE: src/webgpu/lib/nodes/WGSLTagFnNode.js
class LiteralExpression (line 4) | class LiteralExpression extends Node {
method constructor (line 6) | constructor( literal ) {
method build (line 13) | build() {
class PropertyRefNode (line 22) | class PropertyRefNode extends Node {
method constructor (line 24) | constructor( node, output = 'property' ) {
method build (line 32) | build( builder ) {
class InlineCallNode (line 42) | class InlineCallNode extends Node {
method constructor (line 44) | constructor( node ) {
method build (line 51) | build( builder ) {
function getIncludeNode (line 60) | function getIncludeNode( arg ) {
function extractIncludes (line 81) | function extractIncludes( args ) {
function normalizeArgs (line 117) | function normalizeArgs( args ) {
function assembleTemplate (line 146) | function assembleTemplate( tokens, args, builder ) {
class WGSLTagFnNode (line 177) | class WGSLTagFnNode extends FunctionNode {
method type (line 179) | static get type() {
method constructor (line 185) | constructor( tokens, args, lang = 'wgsl' ) {
method getNodeFunction (line 195) | getNodeFunction( builder ) {
method generate (line 257) | generate( builder, output ) {
class WGSLTagCodeNode (line 272) | class WGSLTagCodeNode extends CodeNode {
method type (line 274) | static get type() {
method constructor (line 280) | constructor( tokens, args, lang = 'wgsl' ) {
method build (line 289) | build( builder, output ) {
method generate (line 303) | generate( builder ) {
FILE: src/webgpu/utils/ComputeKernel.js
class ComputeKernel (line 1) | class ComputeKernel {
method computeNode (line 3) | get computeNode() {
method workgroupSize (line 9) | get workgroupSize() {
method needsUpdate (line 15) | set needsUpdate( v ) {
method constructor (line 22) | constructor( fn, options = {} ) {
method defineUniformAccessors (line 36) | defineUniformAccessors( parameters ) {
method setWorkgroupSize (line 68) | setWorkgroupSize( x = 64, y = 1, z = 1 ) {
method getDispatchSize (line 75) | getDispatchSize( tx = 1, ty = 1, tz = 1, target = [] ) {
FILE: src/worker/SilhouetteGeneratorWorker.js
constant NAME (line 4) | const NAME = 'SilhouetteGeneratorWorker';
class SilhouetteGeneratorWorker (line 5) | class SilhouetteGeneratorWorker {
method constructor (line 7) | constructor() {
method generate (line 27) | generate( geometry, options = {} ) {
method dispose (line 125) | dispose() {
FILE: src/worker/silhouetteAsync.worker.js
function onProgressCallback (line 7) | function onProgressCallback( progress ) {
FILE: utils/CommandUtils.js
function findRootDir (line 11) | function findRootDir( urlOrPath = import.meta.url ) {
FILE: utils/docs/RenderDocsUtils.js
function resolveLinks (line 2) | function resolveLinks( str ) {
function renderAlertTags (line 14) | function renderAlertTags( doc ) {
function toAnchor (line 40) | function toAnchor( name ) {
function formatCallbackType (line 48) | function formatCallbackType( callbackDoc, callbackMap ) {
function formatType (line 69) | function formatType( typeObj, callbackMap = {} ) {
function formatParam (line 84) | function formatParam( param, callbackMap = {} ) {
function renderParamLines (line 100) | function renderParamLines( allParams, callbackMap ) {
function renderConstructor (line 177) | function renderConstructor( classDoc, callbackMap = {} ) {
function renderMember (line 217) | function renderMember( doc, callbackMap = {} ) {
function renderCallable (line 245) | function renderCallable( doc, heading, sigPrefix, callbackMap ) {
function renderMethod (line 309) | function renderMethod( doc, callbackMap = {} ) {
function renderFunction (line 318) | function renderFunction( doc, callbackMap = {} ) {
function renderFunctions (line 324) | function renderFunctions( funcs, title = 'Functions', callbackMap = {}, ...
function renderConstants (line 349) | function renderConstants( constants, title = 'Constants', callbackMap = ...
function renderTypedef (line 381) | function renderTypedef( typeDoc, callbackMap = {}, resolveLink = null, h...
function renderEvents (line 434) | function renderEvents( events, callbackMap = {} ) {
function renderComponent (line 486) | function renderComponent( doc, callbackMap = {} ) {
function renderClass (line 551) | function renderClass( classDoc, members, callbackMap = {}, resolveLink =...
FILE: utils/docs/build.js
constant ROOT_DIR (line 7) | const ROOT_DIR = findRootDir();
constant ENTRY_POINTS (line 9) | const ENTRY_POINTS = [
function groupByTag (line 218) | function groupByTag( docs, predicate, defaultGroup ) {
function runJsDoc (line 234) | function runJsDoc( source ) {
function topologicalSortClasses (line 244) | function topologicalSortClasses( classes ) {
function filterDocumented (line 298) | function filterDocumented( json ) {
Condensed preview — 75 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (281K chars).
[
{
"path": ".editorconfig",
"chars": 157,
"preview": "root = true\n\n[*]\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_sty"
},
{
"path": ".github/FUNDING.yml",
"chars": 643,
"preview": "# These are supported funding model platforms\n\ngithub: gkjohnson\npatreon: # Replace with a single Patreon username\nopen_"
},
{
"path": ".github/workflows/examples-build.yml",
"chars": 758,
"preview": "name: Deploy Examples to GitHub Pages\n\non:\n push:\n branches: [ main ]\n\npermissions:\n contents: read\n pages: write\n"
},
{
"path": ".github/workflows/node.js.yml",
"chars": 724,
"preview": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versi"
},
{
"path": ".gitignore",
"chars": 2048,
"preview": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\nlerna-debug.log*\n.pnpm-debug.log*\n\n# Diagnostic reports"
},
{
"path": "API.md",
"chars": 6701,
"preview": "<!-- This file is generated automatically. Do not edit it directly. -->\n# three-edge-projection\n\n## Constants\n\n### OUTPU"
},
{
"path": "CHANGELOG.md",
"chars": 2634,
"preview": "# Changelog\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changel"
},
{
"path": "LICENSE",
"chars": 1072,
"preview": "MIT License\n\nCopyright (c) 2023 Garrett Johnson\n\nPermission is hereby granted, free of charge, to any person obtaining a"
},
{
"path": "README.md",
"chars": 2727,
"preview": "# three-edge-projection\n\n\n[ {\n\n\t\treturn this.primitive"
},
{
"path": "src/utils/ProjectionEdge.js",
"chars": 255,
"preview": "import { Line3 } from 'three';\n\nexport class ProjectionEdge extends Line3 {\n\n\tconstructor( start, end ) {\n\n\t\tsuper( star"
},
{
"path": "src/utils/bvhcastEdges.js",
"chars": 3219,
"preview": "import { isLineTriangleEdge } from './triangleLineUtils.js';\nimport { trimToBeneathTriPlane } from './trimToBeneathTriPl"
},
{
"path": "src/utils/compressPoints.js",
"chars": 1208,
"preview": "const DIRECTION_EPSILON = 1e-3;\nconst DIST_EPSILON = 1e2;\n\nfunction sameDirection( p0, p1, p2 ) {\n\n\tconst dx1 = p1.x - p"
},
{
"path": "src/utils/generateEdges.js",
"chars": 5348,
"preview": "import { Vector3, Triangle, MathUtils, Matrix4 } from 'three';\nimport { ProjectionEdge } from './ProjectionEdge.js';\n\n//"
},
{
"path": "src/utils/generateIntersectionEdges.js",
"chars": 1332,
"preview": "import { Line3 } from 'three';\nimport { isLineTriangleEdge } from './triangleLineUtils.js';\nimport { ProjectionEdge } fr"
},
{
"path": "src/utils/geometryUtils.js",
"chars": 175,
"preview": "export function getTriCount( geometry ) {\n\n\tconst { index } = geometry;\n\tconst posAttr = geometry.attributes.position;\n\t"
},
{
"path": "src/utils/getAllMeshes.js",
"chars": 353,
"preview": "export function getAllMeshes( scene ) {\n\n\tlet arr;\n\tif ( Array.isArray( scene ) ) {\n\n\t\tarr = scene;\n\n\t} else {\n\n\t\tarr = "
},
{
"path": "src/utils/getProjectedLineOverlap.js",
"chars": 3347,
"preview": "import { Vector3, Line3, Plane } from 'three';\nimport { ExtendedTriangle } from 'three-mesh-bvh';\n\nconst AREA_EPSILON = "
},
{
"path": "src/utils/getProjectedOverlaps.js",
"chars": 2007,
"preview": "import { Vector3 } from 'three';\nconst DIST_EPSILON = 1e-16;\nconst _dir = /* @__PURE__ */ new Vector3();\nconst _v0 = /* "
},
{
"path": "src/utils/getSizeSortedTriList.js",
"chars": 1041,
"preview": "import { Triangle } from 'three';\nimport { getTriCount } from './geometryUtils.js';\n\nconst _tri = new Triangle();\nexport"
},
{
"path": "src/utils/nextFrame.js",
"chars": 291,
"preview": "export const nextFrame = () => new Promise( resolve => {\n\n\tlet rafHandle;\n\tlet timeoutHandle;\n\tconst cb = () => {\n\n\t\tcan"
},
{
"path": "src/utils/overlapUtils.js",
"chars": 1000,
"preview": "import { Line3 } from 'three';\n\nconst _line = /* @__PURE__ */ new Line3();\n\n// Converts the given array of overlaps into"
},
{
"path": "src/utils/planeUtils.js",
"chars": 757,
"preview": "import { Vector3, Line3 } from \"three\";\n\nconst _line = /* @__PURE__ */ new Line3();\nconst _v0 = /* @__PURE__ */ new Vect"
},
{
"path": "src/utils/triangleIsInsidePaths.js",
"chars": 2142,
"preview": "import { Line3, Ray } from 'three';\n\nfunction xzToXzCopy( v, target ) {\n\n\ttarget.x = v.x;\n\ttarget.y = v.z;\n\n}\n\nfunction "
},
{
"path": "src/utils/triangleLineUtils.js",
"chars": 1164,
"preview": "import { Vector3 } from 'three';\n\nconst EPSILON = 1e-16;\nconst UP_VECTOR = /* @__PURE__ */ new Vector3( 0, 1, 0 );\nconst"
},
{
"path": "src/utils/trimToBeneathTriPlane.js",
"chars": 2039,
"preview": "import { Plane, Vector3, MathUtils } from 'three';\n\nconst EPSILON = 1e-16;\nconst UP_VECTOR = /* @__PURE__ */ new Vector3"
},
{
"path": "src/webgpu/MeshVisibilityCuller.js",
"chars": 4870,
"preview": "/** @import { Object3D } from 'three' */\n/** @import { WebGPURenderer } from 'three/webgpu' */\nimport {\n\tBox3,\n\tVector3,"
},
{
"path": "src/webgpu/ProjectionGenerator.js",
"chars": 11666,
"preview": "/** @import { Object3D, BufferGeometry } from 'three' */\n/** @import { WebGPURenderer } from 'three/webgpu' */\nimport { "
},
{
"path": "src/webgpu/ProjectionGeneratorBVHComputeData.js",
"chars": 9621,
"preview": "import { BackSide, DoubleSide, FrontSide } from 'three';\nimport { StructTypeNode } from 'three/webgpu';\nimport { BVHComp"
},
{
"path": "src/webgpu/index.js",
"chars": 85,
"preview": "export * from './ProjectionGenerator.js';\nexport * from './MeshVisibilityCuller.js';\n"
},
{
"path": "src/webgpu/kernels/EdgeOverlapsKernel.js",
"chars": 1621,
"preview": "import { globalId, storage, uniform } from 'three/tsl';\nimport { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js';\nimpor"
},
{
"path": "src/webgpu/kernels/ZeroOutBufferKernel.js",
"chars": 731,
"preview": "import { IndirectStorageBufferAttribute } from 'three/webgpu';\nimport { storage, globalId } from 'three/tsl';\nimport { w"
},
{
"path": "src/webgpu/lib/BVHComputeData.js",
"chars": 26567,
"preview": "import { Matrix4, SkinnedMesh, Vector4 } from 'three';\nimport { Mesh, StorageBufferAttribute, StructTypeNode } from 'thr"
},
{
"path": "src/webgpu/lib/nodes/NodeProxy.js",
"chars": 1960,
"preview": "import { Node } from 'three/webgpu';\n\nclass ProxyCallNode extends Node {\n\n\tstatic get type() {\n\n\t\treturn 'ProxyCallNode'"
},
{
"path": "src/webgpu/lib/nodes/WGSLTagFnNode.js",
"chars": 7023,
"preview": "import { CodeNode, FunctionNode, Node } from 'three/webgpu';\n\n// minimal node that outputs a raw WGSL expression verbati"
},
{
"path": "src/webgpu/lib/wgsl/common.wgsl.js",
"chars": 963,
"preview": "import { wgslFn, uint, float } from 'three/tsl';\nimport { rayStruct } from './structs.wgsl.js';\n\nexport const constants "
},
{
"path": "src/webgpu/lib/wgsl/structs.wgsl.js",
"chars": 747,
"preview": "import { StructTypeNode } from 'three/webgpu';\n\nexport const rayStruct = new StructTypeNode( {\n\torigin: 'vec3f',\n\tdirect"
},
{
"path": "src/webgpu/nodes/common.wgsl.js",
"chars": 275,
"preview": "import { float, int } from 'three/tsl';\n\nexport const constants = {\n\tPARALLEL_EPSILON: float( 1e-10 ),\n\tAREA_EPSILON: fl"
},
{
"path": "src/webgpu/nodes/overlapFunctions.wgsl.js",
"chars": 9864,
"preview": "import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js';\nimport { constants } from './common.wgsl.js';\nimport { TriWGS"
},
{
"path": "src/webgpu/nodes/primitives.js",
"chars": 1414,
"preview": "import { StructTypeNode } from 'three/webgpu';\nimport { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js';\n\nconst lineStr"
},
{
"path": "src/webgpu/nodes/structs.wgsl.js",
"chars": 832,
"preview": "import { StructTypeNode } from 'three/webgpu';\n\nexport const edgeStruct = new StructTypeNode( {\n\tstart: 'array<f32, 3>',"
},
{
"path": "src/webgpu/nodes/utils.wgsl.js",
"chars": 1159,
"preview": "import { wgslTagFn } from '../lib/nodes/WGSLTagFnNode.js';\nimport { bvhNodeBoundsStruct } from '../lib/wgsl/structs.wgsl"
},
{
"path": "src/webgpu/utils/ComputeKernel.js",
"chars": 1378,
"preview": "export class ComputeKernel {\n\n\tget computeNode() {\n\n\t\treturn this.kernel.computeNode;\n\n\t}\n\n\tget workgroupSize() {\n\n\t\tret"
},
{
"path": "src/worker/SilhouetteGeneratorWorker.js",
"chars": 2569,
"preview": "import { BufferAttribute, BufferGeometry } from 'three';\nimport { OUTPUT_BOTH } from '../SilhouetteGenerator';\n\nconst NA"
},
{
"path": "src/worker/silhouetteAsync.worker.js",
"chars": 2003,
"preview": "import { BufferAttribute, BufferGeometry } from 'three';\nimport { OUTPUT_BOTH, SilhouetteGenerator } from '../Silhouette"
},
{
"path": "test/Utils.getProjectedLineOverlap.test.js",
"chars": 1151,
"preview": "import { getProjectedLineOverlap } from '../src/utils/getProjectedLineOverlap.js';\nimport { ExtendedTriangle } from 'thr"
},
{
"path": "test/Utils.triangleLineUtils.test.js",
"chars": 2383,
"preview": "import { Line3, Vector3 } from 'three';\nimport { ExtendedTriangle } from 'three-mesh-bvh';\nimport { isYProjectedTriangle"
},
{
"path": "test/Utils.trimToBeneathTriPlane.test.js",
"chars": 2300,
"preview": "import { ExtendedTriangle } from 'three-mesh-bvh';\nimport { trimToBeneathTriPlane } from '../src/utils/trimToBeneathTriP"
},
{
"path": "utils/CommandUtils.js",
"chars": 782,
"preview": "import { existsSync } from 'fs';\nimport { dirname, join } from 'path';\nimport { fileURLToPath } from 'url';\n\n/**\n * Walk"
},
{
"path": "utils/docs/RenderDocsUtils.js",
"chars": 14375,
"preview": "// Converts {@link url text} inline tags in a string to Markdown [text](url) links.\nexport function resolveLinks( str ) "
},
{
"path": "utils/docs/build.js",
"chars": 8203,
"preview": "import { execSync } from 'child_process';\nimport fs from 'fs';\nimport path from 'path';\nimport { renderClass, renderComp"
},
{
"path": "vite.config.js",
"chars": 549,
"preview": "import { searchForWorkspaceRoot } from 'vite';\nimport fs from 'fs';\n\nexport default {\n\n\troot: './example/',\n\tbase: '',\n\t"
},
{
"path": "vitest.config.js",
"chars": 193,
"preview": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig( {\n\ttest: {\n\t\tglobals: true,\n\t\tenvironment: '"
}
]
About this extraction
This page contains the full source code of the gkjohnson/three-edge-projection GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 75 files (246.7 KB), approximately 71.6k tokens, and a symbol index with 229 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.