├── .prettierignore ├── .gitattributes ├── scripts └── generate_sdf │ ├── .gitignore │ ├── makefile │ ├── Cargo.toml │ └── src │ └── main.rs ├── static ├── favicon.ico ├── models │ ├── sintel-sdf.bin │ ├── SintelHairOriginal-sintel_hair.16points.tfx │ └── cube.obj └── index.html ├── src ├── scene │ ├── sdfCollider │ │ ├── __test__ │ │ │ ├── test-sdf.bin │ │ │ └── sdf-binary.snapshot.bin │ │ ├── sdfUtils.ts │ │ ├── createSdfColliderFromBinary.ts │ │ ├── createSdfColliderFromBinary.test.ts │ │ ├── createMockSdfCollider.ts │ │ └── sdfCollider.ts │ ├── scene.ts │ ├── loaders.types.ts │ ├── gpuMesh.ts │ ├── hair │ │ ├── hairDataBuffer.ts │ │ ├── hairSegmentLengthsBuffer.ts │ │ ├── hairIndexBuffer.ts │ │ ├── hairPointsPositionsBuffer.ts │ │ ├── hairShadingBuffer.ts │ │ ├── hairTangentsBuffer.ts │ │ └── tfxFileLoader.ts │ └── objLoader.ts ├── passes │ ├── swHair │ │ ├── __test__ │ │ │ └── swRasterizer.snapshot.bin │ │ ├── shared │ │ │ ├── utils.ts │ │ │ ├── segmentCountPerTileBuffer.ts │ │ │ ├── tileListBuffer.ts │ │ │ ├── hairRasterizerResultBuffer.ts │ │ │ ├── hairSlicesDataBuffer.ts │ │ │ ├── hairTileSegmentsBuffer.ts │ │ │ └── hairTilesResultBuffer.ts │ │ ├── shaderImpl │ │ │ ├── tileUtils.wgsl.ts │ │ │ ├── reduceHairSlices.wgsl.ts │ │ │ └── processHairSegment.wgsl.ts │ │ ├── hairTileSortPass.sort.wgsl.ts │ │ └── hairTileSortPass.countTiles.wgsl.ts │ ├── shadowMapPass │ │ ├── shared │ │ │ ├── getShadowMapPreviewSize.ts │ │ │ └── getMVP_ShadowSourceMatrix.ts │ │ ├── shadowMapPass.wgsl.ts │ │ └── shadowMapHairPass.wgsl.ts │ ├── _shaderSnippets │ │ ├── aces.wgsl.ts │ │ ├── fullscreenTriangle.wgsl.ts │ │ ├── linearDepth.wgsl.ts │ │ ├── dither.wgsl.ts │ │ ├── nagaFixes.ts │ │ └── noise.wgsl.ts │ ├── aoPass │ │ ├── shared │ │ │ └── textureAo.wgsl.ts │ │ ├── aoPass.wgsl.ts │ │ └── aoPass.ts │ ├── _shared │ │ ├── bindingsCache.ts │ │ ├── volumeDebug.ts │ │ └── shared.ts │ ├── simulation │ │ ├── shaderImpl │ │ │ ├── integration.wgsl.ts │ │ │ ├── collisions.wgsl.ts │ │ │ └── constraints.wgsl.ts │ │ ├── grids │ │ │ ├── densityGradAndWindGrid.wgsl.ts │ │ │ ├── grids.wgsl.ts │ │ │ └── densityVelocityGrid.wgsl.ts │ │ ├── gridPostSimPass.wgsl.ts │ │ ├── gridPostSimPass.ts │ │ ├── gridPreSimPass.ts │ │ └── hairSimIntegrationPass.ts │ ├── passCtx.ts │ ├── drawBackgroundGradient │ │ ├── drawBackgroundGradientPass.wgsl.ts │ │ └── drawBackgroundGradientPass.ts │ ├── drawGridDbg │ │ ├── drawGridDbgPass.ts │ │ └── drawGridDbgPass.wgsl.ts │ ├── presentPass │ │ ├── dbgShadows.wgsl.ts │ │ └── presentPass.wgsl.ts │ ├── drawSdfCollider │ │ ├── drawSdfColliderPass.ts │ │ └── drawSdfColliderPass.wgsl.ts │ ├── hwHair │ │ ├── shaderImpl │ │ │ └── hwRasterizeHair.wgsl.ts │ │ └── hwHairPass.wgsl.ts │ ├── drawGizmo │ │ ├── drawGizmoPass.wgsl.ts │ │ └── drawGizmoPass.ts │ ├── README.md │ ├── hairShadingPass │ │ └── hairShadingPass.ts │ └── hairCombine │ │ └── hairCombinePass.ts ├── sys_web │ ├── htmlUtils.ts │ ├── onGpuProfilerResult.ts │ ├── loadersWeb.ts │ ├── cavasResize.ts │ └── input.ts ├── utils │ ├── string.ts │ ├── errors.ts │ ├── raycast.plane.ts │ ├── arrays.ts │ ├── matrices.ts │ ├── typedArrayView.ts │ ├── index.ts │ ├── raycast.ts │ └── bounds.ts ├── sys_deno │ ├── fakeCanvas.ts │ └── loadersDeno.ts └── camera.ts ├── .github └── workflows │ └── main.yml ├── CHANGELOG.md ├── .gitignore ├── makefile ├── LICENSE ├── deno.json ├── package.json └── esbuild-script.js /.prettierignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.bin binary 2 | *.tfx binary 3 | -------------------------------------------------------------------------------- /scripts/generate_sdf/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | sintel-sdf.bin -------------------------------------------------------------------------------- /scripts/generate_sdf/makefile: -------------------------------------------------------------------------------- 1 | run: 2 | clear && cargo run -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scthe/frostbitten-hair-webgpu/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/models/sintel-sdf.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scthe/frostbitten-hair-webgpu/HEAD/static/models/sintel-sdf.bin -------------------------------------------------------------------------------- /src/scene/sdfCollider/__test__/test-sdf.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scthe/frostbitten-hair-webgpu/HEAD/src/scene/sdfCollider/__test__/test-sdf.bin -------------------------------------------------------------------------------- /src/passes/swHair/__test__/swRasterizer.snapshot.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scthe/frostbitten-hair-webgpu/HEAD/src/passes/swHair/__test__/swRasterizer.snapshot.bin -------------------------------------------------------------------------------- /src/scene/sdfCollider/__test__/sdf-binary.snapshot.bin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/models/SintelHairOriginal-sintel_hair.16points.tfx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Scthe/frostbitten-hair-webgpu/HEAD/static/models/SintelHairOriginal-sintel_hair.16points.tfx -------------------------------------------------------------------------------- /src/passes/shadowMapPass/shared/getShadowMapPreviewSize.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from '../../../utils/index.ts'; 2 | 3 | const MAX_PREVIEW_SIZE = 500; 4 | 5 | export function getShadowMapPreviewSize(viewportSize: Dimensions) { 6 | return Math.floor( 7 | Math.min(MAX_PREVIEW_SIZE, viewportSize.width / 3, viewportSize.height / 3) 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /scripts/generate_sdf/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "generate_sdf" 3 | version = "0.0.0-dev" 4 | edition = "2021" 5 | description = "Generate SDF" 6 | homepage = "https://scthe.github.io/frostbitten-hair-webgpu" 7 | repository = "https://github.com/Scthe/frostbitten-hair-webgpu" 8 | license = "MIT" 9 | 10 | [dependencies] 11 | tobj = "3.2" 12 | mesh_to_sdf = "0.2.1" 13 | 14 | -------------------------------------------------------------------------------- /src/passes/_shaderSnippets/aces.wgsl.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/ 3 | */ 4 | export const SNIPPET_ACES = /* wgsl */ ` 5 | 6 | fn doACES_Tonemapping(x: vec3f) -> vec3f { 7 | let a = 2.51; 8 | let b = 0.03; 9 | let c = 2.43; 10 | let d = 0.59; 11 | let e = 0.14; 12 | return saturate((x*(a*x+b)) / (x*(c*x+d)+e)); 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /src/passes/swHair/shared/utils.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../../constants.ts'; 2 | import { Dimensions, divideCeil } from '../../../utils/index.ts'; 3 | 4 | export const getTileCount = (viewportSize: Dimensions): Dimensions => { 5 | const { tileSize } = CONFIG.hairRender; 6 | return { 7 | width: divideCeil(viewportSize.width, tileSize), 8 | height: divideCeil(viewportSize.height, tileSize), 9 | }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/scene/scene.ts: -------------------------------------------------------------------------------- 1 | import { Mat4 } from 'wgpu-matrix'; 2 | import { GPUMesh } from './gpuMesh.ts'; 3 | import { HairObject } from './hair/hairObject.ts'; 4 | import { SDFCollider } from './sdfCollider/sdfCollider.ts'; 5 | import { GridData } from '../passes/simulation/grids/gridData.ts'; 6 | 7 | export interface Scene { 8 | objects: GPUMesh[]; 9 | hairObject: HairObject; 10 | sdfCollider: SDFCollider; 11 | modelMatrix: Mat4; 12 | physicsGrid: GridData; 13 | } 14 | -------------------------------------------------------------------------------- /static/models/cube.obj: -------------------------------------------------------------------------------- 1 | o Cube 2 | v 1 1 1 3 | v -1 1 1 4 | v -1 -1 1 5 | v 1 -1 1 6 | v 1 1 -1 7 | v -1 1 -1 8 | v -1 -1 -1 9 | v 1 -1 -1 10 | vn 0 0 1 11 | vn 1 0 0 12 | vn -1 0 0 13 | vn 0 0 -1 14 | vn 0 1 0 15 | vn 0 -1 0 16 | f 1//1 2//1 3//1 17 | f 3//1 4//1 1//1 18 | f 5//2 1//2 4//2 19 | f 4//2 8//2 5//2 20 | f 2//3 6//3 7//3 21 | f 7//3 3//3 2//3 22 | f 7//4 8//4 5//4 23 | f 5//4 6//4 7//4 24 | f 5//5 6//5 2//5 25 | f 2//5 1//5 5//5 26 | f 8//6 4//6 3//6 27 | f 3//6 7//6 8//6 -------------------------------------------------------------------------------- /src/scene/loaders.types.ts: -------------------------------------------------------------------------------- 1 | export type TextFileReader = (filename: string) => Promise; 2 | 3 | export type BinaryFileReader = (filename: string) => Promise; 4 | 5 | export type TextureReader = ( 6 | device: GPUDevice, 7 | path: string, 8 | format: GPUTextureFormat, 9 | usage: GPUTextureUsageFlags 10 | ) => Promise; 11 | 12 | /** Progress [0..1] or status */ 13 | export type ObjectLoadingProgressCb = ( 14 | name: string, 15 | msg: number | string 16 | ) => Promise; 17 | -------------------------------------------------------------------------------- /src/passes/_shaderSnippets/fullscreenTriangle.wgsl.ts: -------------------------------------------------------------------------------- 1 | export const FULLSCREEN_TRIANGLE_POSITION = /* wgsl */ ` 2 | 3 | /** https://www.saschawillems.de/blog/2016/08/13/vulkan-tutorial-on-rendering-a-fullscreen-quad-without-buffers/ */ 4 | fn getFullscreenTrianglePosition(vertIdx: u32) -> vec4f { 5 | let outUV = vec2u((vertIdx << 1) & 2, vertIdx & 2); 6 | return vec4f(vec2f(outUV) * 2.0 - 1.0, 0.0, 1.0); 7 | } 8 | `; 9 | 10 | export function cmdDrawFullscreenTriangle(renderPass: GPURenderPassEncoder) { 11 | renderPass.draw(3); 12 | } 13 | -------------------------------------------------------------------------------- /src/passes/aoPass/shared/textureAo.wgsl.ts: -------------------------------------------------------------------------------- 1 | export const TEXTURE_AO = (binding: number) => /* wgsl */ ` 2 | 3 | @group(0) @binding(${binding}) 4 | var _aoTexture: texture_2d; 5 | 6 | // TODO [IGNORE] add blur pass or at least bilinear sampling to smooth AO out 7 | fn sampleAo(viewport: vec2f, positionPx: vec2f) -> f32 { 8 | let aoTexSize = textureDimensions(_aoTexture); 9 | let t = positionPx.xy / viewport.xy; 10 | let aoSamplePx = vec2i(vec2f(aoTexSize) * t); 11 | return textureLoad(_aoTexture, aoSamplePx, 0).r; 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/passes/_shared/bindingsCache.ts: -------------------------------------------------------------------------------- 1 | export class BindingsCache { 2 | private cache: Record = {}; 3 | 4 | getBindings(key: string, factory: () => GPUBindGroup): GPUBindGroup { 5 | const cachedVal = this.cache[key]; 6 | if (cachedVal) { 7 | return cachedVal; 8 | } 9 | 10 | const val = factory(); 11 | this.cache[key] = val; 12 | return val; 13 | } 14 | 15 | clear() { 16 | // Object.values(this.cache).forEach((bg) => { 17 | // bg?.destroy(); // no such fn? 18 | // }); 19 | this.cache = {}; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build-and-deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: corepack enable 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | cache: 'yarn' 20 | 21 | - name: Build 22 | run: | 23 | yarn install 24 | yarn build 25 | touch build/.nojekyll 26 | 27 | - name: Deploy 28 | uses: JamesIves/github-pages-deploy-action@v4.6.4 29 | with: 30 | folder: build 31 | -------------------------------------------------------------------------------- /src/sys_web/htmlUtils.ts: -------------------------------------------------------------------------------- 1 | export const isHtmlElVisible = (el: HTMLElement | null) => { 2 | return el && el.style.display !== 'none'; 3 | }; 4 | 5 | export const showHtmlEl = ( 6 | el: HTMLElement | null, 7 | display: 'block' | 'flex' = 'block' 8 | ) => { 9 | if (el) el.style.display = display; 10 | }; 11 | 12 | export const hideHtmlEl = (el: HTMLElement | null) => { 13 | if (el) el.style.display = 'none'; 14 | }; 15 | 16 | export const ensureHtmlElIsVisible = ( 17 | el: HTMLElement | null, 18 | nextVisible: boolean 19 | ) => { 20 | const isVisible = isHtmlElVisible(el); 21 | if (isVisible === nextVisible) return; 22 | 23 | // console.log('HTML change visible to', nextVisible); 24 | if (nextVisible) { 25 | showHtmlEl(el); 26 | } else { 27 | hideHtmlEl(el); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## Sept 27, 2024 5 | 6 | - Rewritten `HairFinePass` based on ["High-Performance Software Rasterization on GPUs"](https://research.nvidia.com/sites/default/files/pubs/2011-08_High-Performance-Software-Rasterization/laine2011hpg_paper.pdf) by Samuli Laine and Tero Karras. This pass now takes ~3.3ms instead of 10ms. 7 | - Updated README.md. 8 | - Added CHANGELOG.md to list major changes. 9 | 10 | 11 | ## Sept 7, 2024 12 | 13 | - Fixes for user-reported errors. 14 | 15 | 16 | ## Aug 26, 2024 17 | 18 | - Sorting tiles by hair segment count. More stable framerate. 19 | 20 | 21 | ## Aug 19, 2024 22 | 23 | - I have published ["Software rasterizing hair"](https://www.sctheblog.com/blog/hair-software-rasterize/). 24 | 25 | 26 | ## Aug 17, 2024 27 | 28 | - Initial release. 29 | -------------------------------------------------------------------------------- /src/passes/shadowMapPass/shadowMapPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 2 | 3 | export const SHADER_PARAMS = { 4 | bindings: { 5 | renderUniforms: 0, 6 | }, 7 | }; 8 | 9 | /////////////////////////// 10 | /// SHADER CODE 11 | /////////////////////////// 12 | const b = SHADER_PARAMS.bindings; 13 | 14 | export const SHADER_CODE = () => /* wgsl */ ` 15 | 16 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 17 | 18 | 19 | @vertex 20 | fn main_vs( 21 | @location(0) inWorldPos : vec3f, 22 | ) -> @builtin(position) vec4f { 23 | let mvpMatrix = _uniforms.shadows.sourceMVP_Matrix; 24 | return mvpMatrix * vec4(inWorldPos.xyz, 1.0); 25 | } 26 | 27 | 28 | @fragment 29 | fn main_fs() -> @location(0) vec4 { 30 | return vec4(0.0); 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | *.tsbuildinfo 4 | *.code-workspace 5 | node_modules 6 | 7 | # yarn 8 | .pnp.* 9 | .yarn/* 10 | !.yarn/patches 11 | !.yarn/plugins 12 | !.yarn/releases 13 | !.yarn/sdks 14 | !.yarn/versions 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | 19 | # build 20 | build 21 | static/*.pem 22 | renderdoc.dll 23 | *.deno.exe 24 | 25 | # assets 26 | static/models/* 27 | !static/models/cube.obj 28 | !static/models/sintel-collider.obj 29 | !static/models/sintel-eyelashes.obj 30 | !static/models/sintel-eyes.obj 31 | !static/models/sintel-sdf.bin 32 | !static/models/sintel.obj 33 | !static/models/SintelHairOriginal-sintel_hair.16points.tfx 34 | !static/models/sphere.obj 35 | 36 | # ignored test artefacts 37 | src/passes/swHair/__test__/*.png 38 | 39 | # custom 40 | *.code-workspace 41 | _references 42 | output*.png 43 | 44 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | DENO = "C:\\programs\\portable\\deno\\deno.exe" 2 | BLENDER = "C:\\programs\\portable\\blender-4.0.2-windows-x64\\blender.exe" 3 | BLENDER_FILE = "_references/models/frostbite-hair.blend" 4 | 5 | # install dependencies before first run 6 | install: 7 | $(DENO) cache "src/index.deno.ts" 8 | 9 | # Render to an image 10 | run: 11 | $(DENO) task start 12 | # DENO_NO_PACKAGE_JSON=1 && "_references/deno.exe" run --allow-read=. --allow-write=. --unstable-webgpu src/index.deno.ts 13 | 14 | # Run all tests 15 | test: 16 | $(DENO) task test 17 | 18 | testSort: 19 | $(DENO) task testSort 20 | 21 | # Generate .exe 22 | compile: 23 | $(DENO) task compile 24 | 25 | # Export hair object from Blender. Has hardcoded params, but whatever. 26 | export_hair: 27 | $(BLENDER) $(BLENDER_FILE) --background --python "./scripts/tfx_exporter.py" 28 | 29 | # Create SDF file 30 | export_sdf: 31 | cd "scripts\generate_sdf" && cargo run 32 | cp "scripts\generate_sdf\sintel-sdf.bin" "static\models\sintel-sdf.bin" 33 | -------------------------------------------------------------------------------- /src/passes/simulation/shaderImpl/integration.wgsl.ts: -------------------------------------------------------------------------------- 1 | export const HAIR_SIM_IMPL_INTEGRATION = /* wgsl */ ` 2 | 3 | // Positions have .w as isMovable flag. 1.0 if isMovable, 0.0 if is not (strand root). 4 | // Returned as float to avoid branching. Just multiply delta instead. 5 | fn isMovable(p: vec4f) -> f32 { return p.w; } 6 | 7 | /** https://en.wikipedia.org/wiki/Verlet_integration */ 8 | fn verletIntegration ( 9 | dt: f32, 10 | posPrev: vec4f, 11 | posNow: vec4f, 12 | gridDisp: vec3f, 13 | friction: f32, 14 | acceleration: vec3f, 15 | ) -> vec4f { 16 | // original verlet: 17 | // let posNext: vec3f = (2. * posNow.xyz - posPrev.xyz) + acceleration * dt * dt; 18 | 19 | // https://youtu.be/ool2E8SQPGU?si=yKgmYF6Wjbu6HXsF&t=815 20 | let pointDisp = posNow.xyz - posPrev.xyz; 21 | let finalPointDisp = mix(pointDisp, gridDisp, friction); 22 | 23 | let posNext: vec3f = posNow.xyz + finalPointDisp + acceleration * dt * dt; 24 | return vec4f( 25 | mix(posPrev.xyz, posNext, isMovable(posNow)), 26 | posNow.w 27 | ); 28 | } 29 | `; 30 | -------------------------------------------------------------------------------- /src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function formatBytes(bytes: number, decimals = 0) { 2 | if (bytes <= 0) return '0 Bytes'; 3 | 4 | // prettier-ignore 5 | const units = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] 6 | const k = 1024; 7 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 8 | const v = (bytes / Math.pow(k, i)).toFixed(decimals); 9 | return `${v} ${units[i]}`; 10 | } 11 | 12 | export function formatNumber(num: number, decimals = 2) { 13 | if (num === 0) return '0'; 14 | const sign = num < 0 ? '-' : ''; 15 | num = Math.abs(num); 16 | 17 | const units = ['', 'k', 'm', 'b']; 18 | const k = 1000; 19 | const i = Math.floor(Math.log(num) / Math.log(k)); 20 | const v = (num / Math.pow(k, i)).toFixed(decimals); 21 | return `${sign}${v}${units[i]}`; 22 | } 23 | 24 | /** Format 4 out of 100 into: '4 (4%)' */ 25 | export function formatPercentageNumber( 26 | actual: number, 27 | total: number, 28 | decimals = 2 29 | ) { 30 | const percent = total > 0 ? (actual / total) * 100.0 : 0; 31 | return `${formatNumber(actual, decimals)} (${percent.toFixed(1)}%)`; 32 | } 33 | -------------------------------------------------------------------------------- /src/sys_web/onGpuProfilerResult.ts: -------------------------------------------------------------------------------- 1 | import { GpuProfilerResult } from '../gpuProfiler.ts'; 2 | import { showHtmlEl } from '../sys_web/htmlUtils.ts'; 3 | 4 | export function onGpuProfilerResult(result: GpuProfilerResult) { 5 | console.log('Profiler:', result); 6 | const parentEl = document.getElementById('profiler-results')!; 7 | parentEl.innerHTML = ''; 8 | // deno-lint-ignore no-explicit-any 9 | showHtmlEl(parentEl.parentNode as any); 10 | 11 | const mergeByName: Record = {}; 12 | const names = new Set(); 13 | result.forEach(([name, timeMs]) => { 14 | const t = mergeByName[name] || 0; 15 | mergeByName[name] = t + timeMs; 16 | names.add(name); 17 | }); 18 | 19 | let totalMs = 0; 20 | names.forEach((name) => { 21 | const timeMs = mergeByName[name]; 22 | const li = document.createElement('li'); 23 | li.innerHTML = `${name}: ${timeMs.toFixed(2)}ms`; 24 | parentEl.appendChild(li); 25 | totalMs += timeMs; 26 | }); 27 | 28 | const li = document.createElement('li'); 29 | li.innerHTML = `--- TOTAL: ${totalMs.toFixed(2)}ms ---`; 30 | parentEl.appendChild(li); 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Marcin Matuszczyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": { 3 | "start": "DENO_NO_PACKAGE_JSON=1 && deno run --allow-read=. --allow-write=. --unstable-webgpu src/index.deno.ts", 4 | "compile": "DENO_NO_PACKAGE_JSON=1 && deno compile --allow-read=. --allow-write=. --unstable-webgpu src/index.deno.ts", 5 | "test": "DENO_NO_PACKAGE_JSON=1 && deno test --allow-read=. --allow-write=. --unstable-webgpu src", 6 | "testSort": "DENO_NO_PACKAGE_JSON=1 && deno test --allow-read=. --allow-write=. --unstable-webgpu src/passes/swHair/hairTileSortPass.test.ts" 7 | }, 8 | "imports": { 9 | "png": "https://deno.land/x/pngs@0.1.1/mod.ts", 10 | "std/webgpu": "jsr:@std/webgpu@^0.224.0", 11 | "wgpu-matrix": "npm:wgpu-matrix@2.9.0", 12 | "std-path": "https://deno.land/std@0.224.0/path/mod.ts", 13 | "fs": "https://deno.land/std@0.224.0/fs/mod.ts", 14 | "assert": "https://deno.land/std@0.224.0/assert/mod.ts", 15 | "webgl-obj-loader": "npm:webgl-obj-loader@2.0.8" 16 | }, 17 | "test": { 18 | "exclude": ["src/web"] 19 | }, 20 | "compilerOptions": { 21 | "lib": ["esnext", "dom", "dom.iterable", "deno.window"] 22 | }, 23 | "exclude": ["_references/"] 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frostbitten-hair-webgpu", 3 | "description": "Software rasterizing hair strands with analytical AA and OIT. Inspired by Frostbite's hair system: \"Every Strand Counts: Physics and Rendering Behind Frostbite's Hair\".", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "author": "Marcin Matuszczyk ", 7 | "homepage": "https://scthe.github.io/frostbitten-hair-webgpu", 8 | "repository": "https://github.com/Scthe/frostbitten-hair-webgpu", 9 | "main": "src/index.web.tsx", 10 | "packageManager": "yarn@4.2.2", 11 | "scripts": { 12 | "predev": "yarn clean", 13 | "dev": "node esbuild-script.js --dev", 14 | "prebuild": "yarn clean", 15 | "build": "node esbuild-script.js", 16 | "serve": "http-server --ssl -C build/cert.pem -K build/key.pem build", 17 | "clean": "rimraf ./build/* || true" 18 | }, 19 | "dependencies": { 20 | "dat.gui": "^0.7.9", 21 | "webgl-obj-loader": "^2.0.8", 22 | "wgpu-matrix": "^2.9.0" 23 | }, 24 | "devDependencies": { 25 | "@types/dat.gui": "^0.7.9", 26 | "esbuild": "^0.21.3", 27 | "esbuild-copy-static-files": "^0.1.0", 28 | "rimraf": "^5.0.7", 29 | "typescript": "^5.4.5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/scene/sdfCollider/sdfUtils.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_F32 } from '../../constants.ts'; 2 | 3 | /** https://www.w3.org/TR/webgpu/#float32-filterable */ 4 | const TEXTURE_FORMAT: GPUTextureFormat = 'r32float'; 5 | 6 | export function createSDFTexture( 7 | device: GPUDevice, 8 | name: string, 9 | dims: number, 10 | data: Float32Array 11 | ) { 12 | const texSize: GPUExtent3D = { 13 | width: dims, 14 | height: dims, 15 | depthOrArrayLayers: dims, 16 | }; 17 | const tex = device.createTexture({ 18 | label: `${name}-texture`, 19 | dimension: '3d', 20 | size: texSize, 21 | format: TEXTURE_FORMAT, 22 | usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, 23 | }); 24 | device.queue.writeTexture( 25 | { texture: tex }, 26 | data, 27 | { bytesPerRow: BYTES_F32 * dims, rowsPerImage: dims }, 28 | texSize 29 | ); 30 | return tex; 31 | } 32 | 33 | export function createSdfSampler(device: GPUDevice, name: string) { 34 | return device.createSampler({ 35 | label: `${name}-sampler`, 36 | addressModeU: 'clamp-to-edge', 37 | addressModeV: 'clamp-to-edge', 38 | addressModeW: 'clamp-to-edge', 39 | magFilter: 'linear', 40 | minFilter: 'linear', 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | type ErrorCb = (msg: string) => never; 2 | 3 | export function createErrorSystem(device: GPUDevice) { 4 | const ERROR_SCOPES: GPUErrorFilter[] = [ 5 | 'internal', 6 | 'out-of-memory', 7 | 'validation', 8 | ]; 9 | const ERROR_SCOPES_REV = ERROR_SCOPES.toReversed(); 10 | 11 | let currentScopeName = '-'; 12 | 13 | return { 14 | startErrorScope, 15 | reportErrorScopeAsync, 16 | }; 17 | 18 | function startErrorScope(scopeName: string = '-') { 19 | currentScopeName = scopeName; 20 | ERROR_SCOPES.forEach((sc) => device.pushErrorScope(sc)); 21 | } 22 | 23 | async function reportErrorScopeAsync(cb?: ErrorCb) { 24 | let lastError = undefined; 25 | 26 | for (const name of ERROR_SCOPES_REV) { 27 | const err = await device.popErrorScope(); 28 | if (err) { 29 | const msg = `WebGPU error [${currentScopeName}][${name}]: ${err.message}`; 30 | lastError = msg; 31 | if (cb) { 32 | cb(msg); 33 | } else { 34 | console.error(msg); 35 | } 36 | } 37 | } 38 | 39 | return lastError; 40 | } 41 | } 42 | 43 | export const rethrowWebGPUError = (msg: string): never => { 44 | throw new Error(msg); 45 | }; 46 | -------------------------------------------------------------------------------- /src/utils/raycast.plane.ts: -------------------------------------------------------------------------------- 1 | import { Vec3 } from 'webgl-obj-loader'; 2 | import { vec3 } from 'wgpu-matrix'; 3 | import { Ray, getPointAlongRay } from './raycast.ts'; 4 | 5 | // https://github.com/Scthe/Animation-workshop/blob/master/src/gl-utils/raycast/Plane.ts 6 | 7 | const TMP_VEC3 = vec3.create(); 8 | 9 | export interface Plane { 10 | normal: Vec3; 11 | d: number; 12 | } 13 | 14 | /** 15 | * Assuming infinte plane, get intersection, where ray crosses the plane. returns t for ray 16 | * @see https://stackoverflow.com/questions/23975555/how-to-do-ray-plane-intersection 17 | * @see https://www.cs.princeton.edu/courses/archive/fall00/cs426/lectures/raycast/sld017.htm 18 | */ 19 | const planeRayIntersectionDistance = (ray: Ray, plane: Plane) => { 20 | const nom = plane.d + vec3.dot(ray.origin, plane.normal, TMP_VEC3); 21 | const denom = vec3.dot(ray.dir, plane.normal); // project to get how fast we closing in 22 | return -nom / denom; 23 | }; 24 | 25 | /** Assuming infinte plane, get intersection where ray crosses the plane. returns 3d point */ 26 | export const planeRayIntersection = (ray: Ray, plane: Plane, result?: Vec3) => { 27 | const t = planeRayIntersectionDistance(ray, plane); 28 | return getPointAlongRay(ray, t, result); 29 | }; 30 | -------------------------------------------------------------------------------- /src/passes/passCtx.ts: -------------------------------------------------------------------------------- 1 | import { Mat4, Vec3 } from 'wgpu-matrix'; 2 | import { GpuProfiler } from '../gpuProfiler.ts'; 3 | import { Dimensions } from '../utils/index.ts'; 4 | import { Scene } from '../scene/scene.ts'; 5 | import { RenderUniformsBuffer } from './renderUniformsBuffer.ts'; 6 | import { SimulationUniformsBuffer } from './simulation/simulationUniformsBuffer.ts'; 7 | import { GridData } from './simulation/grids/gridData.ts'; 8 | 9 | export interface PassCtx { 10 | frameIdx: number; 11 | device: GPUDevice; 12 | cmdBuf: GPUCommandEncoder; 13 | vpMatrix: Mat4; 14 | viewMatrix: Mat4; 15 | projMatrix: Mat4; 16 | cameraPositionWorldSpace: Vec3; 17 | profiler: GpuProfiler | undefined; 18 | viewport: Dimensions; 19 | scene: Scene; 20 | depthTexture: GPUTextureView; 21 | hdrRenderTexture: GPUTextureView; 22 | normalsTexture: GPUTextureView; 23 | aoTexture: GPUTextureView; 24 | shadowDepthTexture: GPUTextureView; 25 | shadowMapSampler: GPUSampler; 26 | globalUniforms: RenderUniformsBuffer; 27 | simulationUniforms: SimulationUniformsBuffer; 28 | physicsForcesGrid: GridData; 29 | // hair: 30 | hairTilesBuffer: GPUBuffer; 31 | hairTileSegmentsBuffer: GPUBuffer; 32 | hairRasterizerResultsBuffer: GPUBuffer; 33 | hairTileListBuffer: GPUBuffer; 34 | hairSegmentCountPerTileBuffer: GPUBuffer; 35 | } 36 | -------------------------------------------------------------------------------- /src/passes/_shaderSnippets/linearDepth.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../constants.ts'; 2 | 3 | const CAMERA_CFG = CONFIG.camera.projection; 4 | 5 | /** I always forget the order. */ 6 | export const LINEAR_DEPTH = /* wgsl */ ` 7 | 8 | /** Returns value [zNear, zFar] */ 9 | fn linearizeDepth(depth: f32) -> f32 { 10 | let zNear: f32 = ${CAMERA_CFG.near}f; 11 | let zFar: f32 = ${CAMERA_CFG.far}f; 12 | 13 | // PP := projection matrix 14 | // PP[10] = zFar / (zNear - zFar); 15 | // PP[14] = (zFar * zNear) / (zNear - zFar); 16 | // PP[11] = -1 ; PP[15] = 0 ; w = 1 17 | // z = PP[10] * p.z + PP[14] * w; // matrix mul, but x,y do not matter for z,w coords 18 | // w = PP[11] * p.z + PP[15] * w; 19 | // z' = z / w = (zFar / (zNear - zFar) * p.z + (zFar * zNear) / (zNear - zFar)) / (-p.z) 20 | // p.z = (zFar * zNear) / (zFar + (zNear - zFar) * z') 21 | return zNear * zFar / (zFar + (zNear - zFar) * depth); 22 | 23 | // OpenGL: 24 | // let z = depth * 2.0 - 1.0; // back to NDC 25 | // let z = depth; 26 | // return (2.0 * zNear * zFar) / (zFar + zNear - z * (zFar - zNear)); 27 | } 28 | 29 | /** Returns value [0, 1] */ 30 | fn linearizeDepth_0_1(depth: f32) -> f32 { 31 | let zNear: f32 = ${CAMERA_CFG.near}f; 32 | let zFar: f32 = ${CAMERA_CFG.far}f; 33 | let d2 = linearizeDepth(depth); 34 | return d2 / (zFar - zNear); 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /src/utils/arrays.ts: -------------------------------------------------------------------------------- 1 | import { getClassName } from './index.ts'; 2 | 3 | export type TypedArray = Uint32Array | Float32Array | Int32Array; 4 | 5 | export const createArray = (len: number) => Array(len).fill(0); 6 | 7 | type TypedArrayConstructor = new (len: number) => T; 8 | 9 | export function copyToTypedArray( 10 | TypedArrayClass: TypedArrayConstructor, 11 | data: number[] 12 | ): T { 13 | const result = new TypedArrayClass(data.length); 14 | data.forEach((e, idx) => (result[idx] = e)); 15 | return result; 16 | } 17 | 18 | export function ensureTypedArray( 19 | TypedArrayClass: TypedArrayConstructor, 20 | data: T | number[] 21 | ): T { 22 | if (data instanceof TypedArrayClass) { 23 | return data; 24 | } else { 25 | // deno-lint-ignore no-explicit-any 26 | return copyToTypedArray(TypedArrayClass, data as any); 27 | } 28 | } 29 | 30 | export function typedArr2str(arr: TypedArray, delimiter = -1) { 31 | let result = ' '; 32 | arr.forEach((v, i) => { 33 | result += v; 34 | const isLast = i === arr.length - 1; 35 | if ((i + 1) % delimiter === 0) { 36 | result += ',\n'; 37 | if (!isLast) result += ' '; 38 | } else if (!isLast) { 39 | result += ', '; 40 | } 41 | }); 42 | 43 | return `${getClassName(arr)}(len=${arr.length}, bytes=${arr.byteLength}) [\n${result}]`; // prettier-ignore 44 | } 45 | -------------------------------------------------------------------------------- /src/scene/gpuMesh.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_VEC3, BYTES_VEC2 } from '../constants.ts'; 2 | import { Bounds3d } from '../utils/bounds.ts'; 3 | 4 | /** Original mesh as imported from the OBJ file */ 5 | export interface GPUMesh { 6 | name: string; 7 | vertexCount: number; 8 | triangleCount: number; 9 | positionsBuffer: GPUBuffer; 10 | normalsBuffer: GPUBuffer; 11 | uvBuffer: GPUBuffer; 12 | indexBuffer: GPUBuffer; 13 | bounds: Bounds3d; 14 | /** Object rendered just to show where collider is */ 15 | isColliderPreview: boolean; 16 | } 17 | 18 | export const VERTEX_ATTRIBUTE_POSITION: GPUVertexBufferLayout = { 19 | attributes: [ 20 | { 21 | shaderLocation: 0, // position 22 | offset: 0, 23 | format: 'float32x3', 24 | }, 25 | ], 26 | arrayStride: BYTES_VEC3, 27 | stepMode: 'vertex', 28 | }; 29 | 30 | export const VERTEX_ATTRIBUTES: GPUVertexBufferLayout[] = [ 31 | VERTEX_ATTRIBUTE_POSITION, 32 | { 33 | attributes: [ 34 | { 35 | shaderLocation: 1, // normals 36 | offset: 0, 37 | format: 'float32x3', // only nanite object uses octahedron normals 38 | }, 39 | ], 40 | arrayStride: BYTES_VEC3, 41 | stepMode: 'vertex', 42 | }, 43 | { 44 | attributes: [ 45 | { 46 | shaderLocation: 2, // uv 47 | offset: 0, 48 | format: 'float32x2', 49 | }, 50 | ], 51 | arrayStride: BYTES_VEC2, 52 | stepMode: 'vertex', 53 | }, 54 | ]; 55 | -------------------------------------------------------------------------------- /src/sys_web/loadersWeb.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TextFileReader, 3 | BinaryFileReader, 4 | TextureReader, 5 | } from '../scene/loaders.types.ts'; 6 | 7 | export const textFileReader_Web: TextFileReader = async (filename: string) => { 8 | const objFileResp = await fetch(filename); 9 | if (!objFileResp.ok) { 10 | throw `Could not download mesh file '${filename}'`; 11 | } 12 | return objFileResp.text(); 13 | }; 14 | 15 | export const binaryFileReader_Web: BinaryFileReader = async ( 16 | filename: string 17 | ) => { 18 | const response = await fetch(filename); 19 | return response.arrayBuffer(); 20 | }; 21 | 22 | /** https://webgpu.github.io/webgpu-samples/?sample=texturedCube#main.ts */ 23 | export const createTextureFromFile_Web: TextureReader = async ( 24 | device: GPUDevice, 25 | path: string, 26 | format: GPUTextureFormat, 27 | usage: GPUTextureUsageFlags 28 | ) => { 29 | const response = await fetch(path); 30 | const imageBitmap = await createImageBitmap(await response.blob()); 31 | 32 | const texture = device.createTexture({ 33 | label: path, 34 | dimension: '2d', 35 | size: [imageBitmap.width, imageBitmap.height, 1], 36 | format, 37 | usage, 38 | }); 39 | // deno-lint-ignore no-explicit-any 40 | (device.queue as any).copyExternalImageToTexture( 41 | { source: imageBitmap }, 42 | { texture: texture }, 43 | [imageBitmap.width, imageBitmap.height] 44 | ); 45 | 46 | return texture; 47 | }; 48 | -------------------------------------------------------------------------------- /src/passes/swHair/shared/segmentCountPerTileBuffer.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_U32 } from '../../../constants.ts'; 2 | import { Dimensions } from '../../../utils/index.ts'; 3 | import { StorageAccess, u32_type } from '../../../utils/webgpu.ts'; 4 | import { getTileCount } from './utils.ts'; 5 | 6 | /////////////////////////// 7 | /// SHADER CODE 8 | /////////////////////////// 9 | 10 | export const BUFFER_SEGMENT_COUNT_PER_TILE = ( 11 | bindingIdx: number, 12 | access: StorageAccess 13 | ) => /* wgsl */ ` 14 | 15 | @group(0) @binding(${bindingIdx}) 16 | var _hairSegmentCountPerTile: array<${u32_type(access)}>; 17 | 18 | ${access == 'read_write' ? incTileSegmentCount : ''} 19 | `; 20 | 21 | const incTileSegmentCount = /* wgsl */ ` 22 | fn _incTileSegmentCount(viewportSize: vec2u, tileXY: vec2u) { 23 | let tileIdx = getHairTileIdx(viewportSize, tileXY); 24 | atomicAdd(&_hairSegmentCountPerTile[tileIdx], 1u); 25 | } 26 | `; 27 | 28 | /////////////////////////// 29 | /// GPU BUFFER 30 | /////////////////////////// 31 | 32 | export function createHairSegmentCountPerTileBuffer( 33 | device: GPUDevice, 34 | viewportSize: Dimensions 35 | ): GPUBuffer { 36 | const tileCount = getTileCount(viewportSize); 37 | const entries = tileCount.width * tileCount.height; 38 | const size = entries * BYTES_U32; 39 | 40 | return device.createBuffer({ 41 | label: `hair-segment-count-per-tile`, 42 | size, 43 | usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/scene/sdfCollider/createSdfColliderFromBinary.ts: -------------------------------------------------------------------------------- 1 | import { SDFCollider } from './sdfCollider.ts'; 2 | import { BoundingBox } from '../../utils/bounds.ts'; 3 | import { createSDFTexture, createSdfSampler } from './sdfUtils.ts'; 4 | 5 | export function createSdfColliderFromBinary( 6 | device: GPUDevice, 7 | name: string, 8 | data: ArrayBuffer 9 | ): SDFCollider { 10 | const dims = new Uint32Array(data, 0, 1)[0]; 11 | 12 | let offset = 2; // don't know, don't care 13 | const dataF32 = new Float32Array(data); 14 | const bounds: BoundingBox = [ 15 | [dataF32[offset + 0], dataF32[offset + 1], dataF32[offset + 2]], 16 | [dataF32[offset + 3], dataF32[offset + 4], dataF32[offset + 5]], 17 | ]; 18 | offset += 6; 19 | 20 | // load positions 21 | const distances = dataF32.slice(offset); 22 | /*console.log({ 23 | dims, 24 | bounds, 25 | distances: { 26 | first: distances[0], 27 | last: distances.at(-1), 28 | len: distances.length, 29 | }, 30 | });*/ 31 | 32 | const expLen = dims * dims * dims; 33 | if (distances.length !== expLen) { 34 | throw new Error(`Invalid SDF binary file. With dims=${dims} expected ${expLen} values. Got ${distances.length}.`); // prettier-ignore 35 | } 36 | 37 | // create result 38 | const tex = createSDFTexture(device, name, dims, distances); 39 | const texView = tex.createView(); 40 | const sampler = createSdfSampler(device, name); 41 | 42 | return new SDFCollider(name, bounds, dims, tex, texView, sampler); 43 | } 44 | -------------------------------------------------------------------------------- /src/scene/hair/hairDataBuffer.ts: -------------------------------------------------------------------------------- 1 | import { BoundingSphere } from '../../utils/bounds.ts'; 2 | import { TypedArrayView } from '../../utils/typedArrayView.ts'; 3 | import { 4 | WEBGPU_MINIMAL_BUFFER_SIZE, 5 | createGPU_StorageBuffer, 6 | } from '../../utils/webgpu.ts'; 7 | import { TfxFileData } from './tfxFileLoader.ts'; 8 | 9 | /////////////////////////// 10 | /// SHADER CODE 11 | /////////////////////////// 12 | 13 | export const BUFFER_HAIR_DATA = (bindingIdx: number) => /* wgsl */ ` 14 | 15 | struct HairData { 16 | boundingSphere: vec4f, 17 | strandsCount: u32, 18 | pointsPerStrand: u32, 19 | }; 20 | 21 | @group(0) @binding(${bindingIdx}) 22 | var _hairData: HairData; 23 | `; 24 | 25 | /////////////////////////// 26 | /// GPU BUFFER 27 | /////////////////////////// 28 | 29 | export function createHairDataBuffer( 30 | device: GPUDevice, 31 | name: string, 32 | tfxData: TfxFileData, 33 | boundingSphere: BoundingSphere 34 | ): GPUBuffer { 35 | const BYTES = WEBGPU_MINIMAL_BUFFER_SIZE; 36 | 37 | const data = new ArrayBuffer(BYTES); 38 | const dataView = new TypedArrayView(data); 39 | dataView.writeF32(boundingSphere.center[0]); 40 | dataView.writeF32(boundingSphere.center[1]); 41 | dataView.writeF32(boundingSphere.center[2]); 42 | dataView.writeF32(boundingSphere.radius); 43 | dataView.writeU32(tfxData.header.numHairStrands); 44 | dataView.writeU32(tfxData.header.numVerticesPerStrand); 45 | 46 | return createGPU_StorageBuffer(device, `${name}-hair-data`, dataView.asU32); 47 | } 48 | -------------------------------------------------------------------------------- /src/passes/drawBackgroundGradient/drawBackgroundGradientPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { FULLSCREEN_TRIANGLE_POSITION } from '../_shaderSnippets/fullscreenTriangle.wgsl.ts'; 2 | import { SNIPPET_NOISE } from '../_shaderSnippets/noise.wgsl.ts'; 3 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 4 | 5 | export const SHADER_PARAMS = { 6 | bindings: { 7 | renderUniforms: 0, 8 | }, 9 | }; 10 | 11 | /////////////////////////// 12 | /// SHADER CODE 13 | /////////////////////////// 14 | const b = SHADER_PARAMS.bindings; 15 | 16 | export const SHADER_CODE = () => /* wgsl */ ` 17 | 18 | ${FULLSCREEN_TRIANGLE_POSITION} 19 | ${SNIPPET_NOISE} 20 | 21 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 22 | 23 | 24 | @vertex 25 | fn main_vs( 26 | @builtin(vertex_index) VertexIndex : u32 27 | ) -> @builtin(position) vec4f { 28 | return getFullscreenTrianglePosition(VertexIndex); 29 | } 30 | 31 | 32 | @fragment 33 | fn main_fs( 34 | @builtin(position) positionPxF32: vec4 35 | ) -> @location(0) vec4 { 36 | let color0 = _uniforms.background.color0; 37 | let color1 = _uniforms.background.color1; 38 | let noiseScale = _uniforms.background.noiseScale; 39 | let gradientStrength = _uniforms.background.gradientStrength; 40 | 41 | // get noise 42 | let uv = positionPxF32.xy / _uniforms.viewport.xy; 43 | let c = fractalNoise(uv, noiseScale) * 0.5 + 0.5; 44 | 45 | // mix colors 46 | var color = mix(color0, color1, c); 47 | color = mix(color, vec3f(1.0 - uv.y), gradientStrength); 48 | return vec4(color.xyz, 1.0); 49 | } 50 | 51 | `; 52 | -------------------------------------------------------------------------------- /src/passes/_shaderSnippets/dither.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { IS_WGPU } from '../../constants.ts'; 2 | 3 | /** usual 8x8 Bayer matrix dithering */ 4 | export const SNIPPET_DITHER = /* wgsl */ ` 5 | 6 | const DITHER_ELEMENT_RANGE: f32 = 63.0; 7 | 8 | /** No. of possible colors in u8 color value */ 9 | const DITHER_LINEAR_COLORSPACE_COLORS: f32 = 256.0; 10 | 11 | // Too lazy to use texture or smth 12 | const DITHER_MATRIX = array( 13 | 0, 32, 8, 40, 2, 34, 10, 42, 14 | 48, 16, 56, 24, 50, 18, 58, 26, 15 | 12, 44, 4, 36, 14, 46, 6, 38, 16 | 60, 28, 52, 20, 62, 30, 54, 22, 17 | 3, 35, 11, 43, 1, 33, 9, 41, 18 | 51, 19, 59, 27, 49, 17, 57, 25, 19 | 15, 47, 7, 39, 13, 45, 5, 37, 20 | 63, 31, 55, 23, 61, 29, 53, 21 21 | ); 22 | 23 | /** Returns 0-1 dithered value 24 | * @ Param gl_FragCoord - fragment coordinate (in pixels) 25 | */ 26 | fn getDitherForPixel(gl_FragCoord: vec2u) -> f32 { 27 | let pxPos = vec2u( 28 | gl_FragCoord.x % 8u, 29 | gl_FragCoord.y % 8u 30 | ); 31 | let idx = pxPos.y * 8u + pxPos.x; 32 | // Disabled on Deno, as Naga does not allow indexing 'array' 33 | // with nonconst values. See 'nagaFixes.ts'. 34 | let matValue = DITHER_MATRIX[${IS_WGPU ? '0' : 'idx'}]; // [1-64] 35 | return f32(matValue) / DITHER_ELEMENT_RANGE; 36 | } 37 | 38 | /** 39 | * Add some random value to each pixel, 40 | * hoping it would make it different than neighbours 41 | */ 42 | fn ditherColor ( 43 | gl_FragCoord: vec2u, 44 | originalColor: vec3f, 45 | strength: f32 46 | ) -> vec3f { 47 | let ditherMod = getDitherForPixel(gl_FragCoord) * strength / DITHER_LINEAR_COLORSPACE_COLORS; 48 | return originalColor + ditherMod; 49 | } 50 | 51 | `; 52 | -------------------------------------------------------------------------------- /src/utils/matrices.ts: -------------------------------------------------------------------------------- 1 | import { Mat4, mat4, vec4, Vec4, Vec3 } from 'wgpu-matrix'; 2 | import { CameraProjection } from '../constants.ts'; 3 | import { Dimensions, dgr2rad } from './index.ts'; 4 | 5 | export function createCameraProjectionMat( 6 | camera: CameraProjection, 7 | viewportSize: Dimensions 8 | ): Mat4 { 9 | const aspectRatio = viewportSize.width / viewportSize.height; 10 | 11 | return mat4.perspective( 12 | dgr2rad(camera.fovDgr), 13 | aspectRatio, 14 | camera.near, 15 | camera.far 16 | ); 17 | } 18 | 19 | export function getViewProjectionMatrix( 20 | viewMat: Mat4, 21 | projMat: Mat4, 22 | result?: Mat4 23 | ): Mat4 { 24 | return mat4.multiply(projMat, viewMat, result); 25 | } 26 | 27 | export function getModelViewProjectionMatrix( 28 | modelMat: Mat4, 29 | viewMat: Mat4, 30 | projMat: Mat4, 31 | result?: Mat4 32 | ): Mat4 { 33 | result = mat4.multiply(viewMat, modelMat, result); 34 | result = mat4.multiply(projMat, result, result); 35 | return result; 36 | } 37 | 38 | export function projectPoint(mvpMatrix: Mat4, p: Vec4 | Vec3, result?: Vec4) { 39 | let v: Vec4; 40 | if (p.length === 4) { 41 | if (p[3] !== 1) { 42 | throw new Error(`Tried to project a point, but provided Vec4 has .w !== 1`); // prettier-ignore 43 | } 44 | v = p; 45 | } else { 46 | v = vec4.create(p[0], p[1], p[2], 1); 47 | } 48 | return vec4.transformMat4(v, mvpMatrix, result); 49 | } 50 | 51 | export function projectPointWithPerspDiv( 52 | mvpMatrix: Mat4, 53 | p: Vec4 | Vec3, 54 | result?: Vec4 55 | ) { 56 | const result2 = projectPoint(mvpMatrix, p, result); 57 | vec4.divScalar(result2, result2[3], result2); 58 | return result2; 59 | } 60 | -------------------------------------------------------------------------------- /src/passes/shadowMapPass/shadowMapHairPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 2 | import * as SHADER_SNIPPETS from '../_shaderSnippets/shaderSnippets.wgls.ts'; 3 | import { BUFFER_HAIR_POINTS_POSITIONS } from '../../scene/hair/hairPointsPositionsBuffer.ts'; 4 | import { BUFFER_HAIR_TANGENTS } from '../../scene/hair/hairTangentsBuffer.ts'; 5 | import { HW_RASTERIZE_HAIR } from '../hwHair/shaderImpl/hwRasterizeHair.wgsl.ts'; 6 | 7 | export const SHADER_PARAMS = { 8 | bindings: { 9 | renderUniforms: 0, 10 | hairPositions: 1, 11 | hairTangents: 2, 12 | }, 13 | }; 14 | 15 | /////////////////////////// 16 | /// SHADER CODE 17 | /// 18 | /// We are using hardware rasterizer as it's less hassle than software one 19 | /////////////////////////// 20 | const b = SHADER_PARAMS.bindings; 21 | 22 | export const SHADER_CODE = () => /* wgsl */ ` 23 | 24 | ${SHADER_SNIPPETS.GET_MVP_MAT} 25 | ${SHADER_SNIPPETS.NORMALS_UTILS} 26 | ${SHADER_SNIPPETS.GENERIC_UTILS} 27 | ${HW_RASTERIZE_HAIR} 28 | 29 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 30 | ${BUFFER_HAIR_POINTS_POSITIONS(b.hairPositions)} 31 | ${BUFFER_HAIR_TANGENTS(b.hairTangents)} 32 | 33 | 34 | @vertex 35 | fn main_vs( 36 | @builtin(vertex_index) inVertexIndex : u32 37 | ) -> @builtin(position) vec4f { 38 | let hwRasterParams = HwHairRasterizeParams( 39 | _uniforms.shadows.sourceModelViewMat, 40 | _uniforms.shadows.sourceProjMatrix, 41 | getShadowFiberRadius(), 42 | inVertexIndex 43 | ); 44 | let hwRasterResult = hwRasterizeHair(hwRasterParams); 45 | 46 | return hwRasterResult.position; 47 | } 48 | 49 | 50 | @fragment 51 | fn main_fs() -> @location(0) vec4 { 52 | return vec4(0.0); 53 | } 54 | `; 55 | -------------------------------------------------------------------------------- /src/sys_web/cavasResize.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions, ensureIntegerDimensions } from '../utils/index.ts'; 2 | 3 | export type ResizeHandler = (viewportSize: Dimensions) => void; 4 | 5 | export function initCanvasResizeSystem( 6 | canvas: HTMLCanvasElement, 7 | canvasContext: CanvasRenderingContext2D 8 | ) { 9 | const sizeNow = getViewportSize(); 10 | canvas.width = sizeNow.width; 11 | canvas.height = sizeNow.height; 12 | console.log('Init canvas size:', sizeNow); 13 | 14 | const listeners: ResizeHandler[] = []; 15 | const addListener = (f: ResizeHandler) => listeners.push(f); 16 | 17 | // Has nothing to do with resize actually. 18 | const getScreenTextureView = (): GPUTextureView => 19 | canvasContext.getCurrentTexture().createView(); 20 | 21 | return { 22 | revalidateCanvasSize, 23 | addListener, 24 | getViewportSize, 25 | getScreenTextureView, 26 | }; 27 | 28 | function revalidateCanvasSize() { 29 | const sizeNow = getViewportSize(); 30 | const hasChanged = 31 | sizeNow.width !== canvas.width || sizeNow.height !== canvas.height; 32 | 33 | if (hasChanged && sizeNow.width && sizeNow.height) { 34 | applyResize(sizeNow); 35 | } 36 | } 37 | 38 | function applyResize(d: Dimensions) { 39 | // console.log('Canvas resize:', d); 40 | canvas.width = d.width; 41 | canvas.height = d.height; 42 | listeners.forEach((l) => l(d)); 43 | } 44 | 45 | function getViewportSize(): Dimensions { 46 | // deno-lint-ignore no-explicit-any 47 | const devicePixelRatio = (window as any).devicePixelRatio || 1; 48 | return ensureIntegerDimensions({ 49 | width: canvas.clientWidth * devicePixelRatio, 50 | height: canvas.clientHeight * devicePixelRatio, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /esbuild-script.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | const copyStaticFiles = require('esbuild-copy-static-files'); 3 | 4 | const config = { 5 | entryPoints: ['./src/index.web.ts'], 6 | outdir: './build', 7 | bundle: true, 8 | define: {}, 9 | loader: { 10 | '.wgsl': 'text', 11 | }, 12 | // plugins 13 | plugins: [ 14 | copyStaticFiles({ 15 | src: './static', 16 | dest: './build', 17 | dereference: true, 18 | errorOnExist: false, 19 | recursive: true, 20 | }), 21 | ], 22 | }; 23 | 24 | const defineProductionFlag = (flag) => 25 | (config.define.IS_PRODUCTION = String(flag)); 26 | 27 | async function buildProd() { 28 | console.log('Executing prod build'); 29 | defineProductionFlag(true); 30 | 31 | config.minify = true; 32 | // config.format = 'esm'; // produces invalid build? some module imports or smth. 'type="module"' in 103 | 104 | 105 | -------------------------------------------------------------------------------- /src/passes/simulation/gridPostSimPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { BUFFER_HAIR_DATA } from '../../scene/hair/hairDataBuffer.ts'; 2 | import { BUFFER_HAIR_POINTS_POSITIONS_R } from '../../scene/hair/hairPointsPositionsBuffer.ts'; 3 | import { GENERIC_UTILS } from '../_shaderSnippets/shaderSnippets.wgls.ts'; 4 | import { BUFFER_GRID_DENSITY_VELOCITY } from './grids/densityVelocityGrid.wgsl.ts'; 5 | import { GRID_UTILS } from './grids/grids.wgsl.ts'; 6 | import { SimulationUniformsBuffer } from './simulationUniformsBuffer.ts'; 7 | 8 | export const SHADER_PARAMS = { 9 | workgroupSizeX: 32, // TODO [LOW] set better values 10 | bindings: { 11 | simulationUniforms: 0, 12 | hairData: 1, 13 | positionsPrev: 2, 14 | positionsNow: 3, 15 | gridBuffer: 4, 16 | }, 17 | }; 18 | 19 | /////////////////////////// 20 | /// SHADER CODE 21 | /////////////////////////// 22 | const c = SHADER_PARAMS; 23 | const b = SHADER_PARAMS.bindings; 24 | 25 | export const SHADER_CODE = () => /* wgsl */ ` 26 | 27 | ${GENERIC_UTILS} 28 | ${GRID_UTILS} 29 | 30 | ${SimulationUniformsBuffer.SHADER_SNIPPET(b.simulationUniforms)} 31 | ${BUFFER_GRID_DENSITY_VELOCITY(b.gridBuffer, 'read_write')} 32 | ${BUFFER_HAIR_DATA(b.hairData)} 33 | ${BUFFER_HAIR_POINTS_POSITIONS_R(b.positionsPrev, { 34 | bufferName: '_hairPointPositionsPrev', 35 | getterName: '_getHairPointPositionPrev', 36 | })} 37 | ${BUFFER_HAIR_POINTS_POSITIONS_R(b.positionsNow, { 38 | bufferName: '_hairPointPositionsNow', 39 | getterName: '_getHairPointPositionNow', 40 | })} 41 | 42 | 43 | // Everything is in object space (unless noted otherwise). 44 | // The comments assume 32 points per strand to make it easier 45 | @compute 46 | @workgroup_size(${c.workgroupSizeX}, 1, 1) 47 | fn main( 48 | @builtin(global_invocation_id) global_id: vec3, 49 | ) { 50 | let strandsCount: u32 = _hairData.strandsCount; 51 | let pointsPerStrand: u32 = _hairData.pointsPerStrand; // 32 52 | // let segmentCount: u32 = pointsPerStrand - 1u; // 31 53 | let boundsMin = _uniforms.gridData.boundsMin.xyz; 54 | let boundsMax = _uniforms.gridData.boundsMax.xyz; 55 | 56 | let strandIdx = global_id.x; 57 | if (strandIdx >= strandsCount) { return; } 58 | // let isInvalidDispatch = strandIdx >= strandsCount; // some memory accesses will return garbage. It's OK as long as we don't try to override real data? 59 | 60 | for (var i = 0u; i < pointsPerStrand; i += 1u) { 61 | let posPrev = _getHairPointPositionPrev(pointsPerStrand, strandIdx, i).xyz; 62 | let posNow = _getHairPointPositionNow (pointsPerStrand, strandIdx, i).xyz; 63 | let velocity = posNow - posPrev; // we can div. by velocity during integration 64 | 65 | // density is just a 'I am here' counter. Weighted by triliner interpolation 66 | _addGridDensityVelocity( 67 | boundsMin, boundsMax, 68 | posNow, 69 | velocity 70 | ); 71 | } 72 | } 73 | 74 | `; 75 | -------------------------------------------------------------------------------- /src/scene/hair/tfxFileLoader.ts: -------------------------------------------------------------------------------- 1 | export interface TfxFileHeader { 2 | // Specifies TressFX version number 3 | version: number; // float version; 4 | 5 | // Number of hair strands in this file. All strands in this file are guide strands. 6 | // Follow hair strands are generated procedurally. 7 | numHairStrands: number; // unsigned int numHairStrands; 8 | 9 | // From 4 to 64 inclusive (POW2 only). This should be a fixed value within tfx value. 10 | // The total vertices from the tfx file is numHairStrands * numVerticesPerStrand. 11 | numVerticesPerStrand: number; // unsigned int numVerticesPerStrand; 12 | 13 | // Offsets to array data starts here. Offset values are in bytes, aligned on 8 bytes boundaries, 14 | // and relative to beginning of the .tfx file 15 | // ALL ARE UNSIGNED INTS! 16 | offsetVertexPosition: number; // Array size: FLOAT4[numHairStrands] 17 | offsetStrandUV: number; // Array size: FLOAT2[numHairStrands], if 0 no texture coordinates 18 | offsetVertexUV: number; // Array size: FLOAT2[numHairStrands * numVerticesPerStrand], if 0, no per vertex texture coordinates 19 | offsetStrandThickness: number; // Array size: float[numHairStrands] 20 | offsetVertexColor: number; // Array size: FLOAT4[numHairStrands * numVerticesPerStrand], if 0, no vertex colors 21 | 22 | // unsigned int reserved[32]; // Reserved for future versions 23 | } 24 | 25 | export interface TfxFileData { 26 | header: TfxFileHeader; 27 | /** `array` */ 28 | vertexPositions: Float32Array; 29 | } 30 | 31 | const parseHeader = (rawData: ArrayBuffer): TfxFileHeader => { 32 | const version = new Float32Array(rawData, 0, 1)[0]; // offset 0bytes, read single float 33 | const uints = new Uint32Array(rawData, 4, 7); // offset 4bytes cause version is float, read 7 uint-values 34 | 35 | return { 36 | version, 37 | numHairStrands: uints[0], 38 | numVerticesPerStrand: uints[1], 39 | offsetVertexPosition: uints[2], 40 | offsetStrandUV: uints[3], 41 | offsetVertexUV: uints[4], 42 | offsetStrandThickness: uints[5], 43 | offsetVertexColor: uints[6], 44 | }; 45 | }; 46 | 47 | export const parseTfxFile = ( 48 | fileData: ArrayBuffer, 49 | scale: number 50 | ): TfxFileData => { 51 | const header = parseHeader(fileData); 52 | console.log('Loaded Tfx file with header', header); 53 | 54 | const totalVertices = header.numHairStrands * header.numVerticesPerStrand; 55 | const posCount = totalVertices * 4; 56 | const vertexPositions0 = new Float32Array( 57 | fileData, 58 | header.offsetVertexPosition, 59 | posCount 60 | ); 61 | // Deno requires a copy, or it will fail upload to GPU with: 62 | // "Copy size 2406 does not respect `COPY_BUFFER_ALIGNMENT`" 63 | const vertexPositions = vertexPositions0.map((e) => e * scale); 64 | 65 | return { 66 | header, 67 | vertexPositions, 68 | }; 69 | }; 70 | -------------------------------------------------------------------------------- /src/passes/drawBackgroundGradient/drawBackgroundGradientPass.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../constants.ts'; 2 | import { assertIsGPUTextureView } from '../../utils/webgpu.ts'; 3 | import { cmdDrawFullscreenTriangle } from '../_shaderSnippets/fullscreenTriangle.wgsl.ts'; 4 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 5 | import { 6 | labelShader, 7 | labelPipeline, 8 | useColorAttachment, 9 | assignResourcesToBindings, 10 | } from '../_shared/shared.ts'; 11 | import { PassCtx } from '../passCtx.ts'; 12 | import { 13 | SHADER_CODE, 14 | SHADER_PARAMS, 15 | } from './drawBackgroundGradientPass.wgsl.ts'; 16 | 17 | export class DrawBackgroundGradientPass { 18 | public static NAME: string = 'DrawBackgroundGradientPass'; 19 | 20 | private readonly pipeline: GPURenderPipeline; 21 | private readonly bindingsCache = new BindingsCache(); 22 | 23 | constructor(device: GPUDevice, outTextureFormat: GPUTextureFormat) { 24 | const shaderModule = device.createShaderModule({ 25 | label: labelShader(DrawBackgroundGradientPass), 26 | code: SHADER_CODE(), 27 | }); 28 | 29 | this.pipeline = device.createRenderPipeline({ 30 | label: labelPipeline(DrawBackgroundGradientPass), 31 | layout: 'auto', 32 | vertex: { 33 | module: shaderModule, 34 | entryPoint: 'main_vs', 35 | buffers: [], 36 | }, 37 | fragment: { 38 | module: shaderModule, 39 | entryPoint: 'main_fs', 40 | targets: [{ format: outTextureFormat }], 41 | }, 42 | primitive: { topology: 'triangle-list' }, 43 | }); 44 | } 45 | 46 | onViewportResize = () => this.bindingsCache.clear(); 47 | 48 | cmdDraw(ctx: PassCtx) { 49 | const { cmdBuf, profiler, hdrRenderTexture } = ctx; 50 | assertIsGPUTextureView(hdrRenderTexture); 51 | 52 | const renderPass = cmdBuf.beginRenderPass({ 53 | label: DrawBackgroundGradientPass.NAME, 54 | colorAttachments: [ 55 | // TBH no need for clear as we override every pixel 56 | useColorAttachment(hdrRenderTexture, CONFIG.clearColor, 'load'), 57 | ], 58 | timestampWrites: profiler?.createScopeGpu( 59 | DrawBackgroundGradientPass.NAME 60 | ), 61 | }); 62 | 63 | const bindings = this.bindingsCache.getBindings('-', () => 64 | this.createBindings(ctx) 65 | ); 66 | renderPass.setBindGroup(0, bindings); 67 | renderPass.setPipeline(this.pipeline); 68 | cmdDrawFullscreenTriangle(renderPass); 69 | renderPass.end(); 70 | } 71 | 72 | private createBindings = ({ 73 | device, 74 | globalUniforms, 75 | }: PassCtx): GPUBindGroup => { 76 | const b = SHADER_PARAMS.bindings; 77 | 78 | return assignResourcesToBindings( 79 | DrawBackgroundGradientPass, 80 | device, 81 | this.pipeline, 82 | [globalUniforms.createBindingDesc(b.renderUniforms)] 83 | ); 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/passes/aoPass/aoPass.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../constants.ts'; 2 | import { assertIsGPUTextureView } from '../../utils/webgpu.ts'; 3 | import { cmdDrawFullscreenTriangle } from '../_shaderSnippets/fullscreenTriangle.wgsl.ts'; 4 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 5 | import { 6 | labelShader, 7 | labelPipeline, 8 | useColorAttachment, 9 | assignResourcesToBindings, 10 | } from '../_shared/shared.ts'; 11 | import { PassCtx } from '../passCtx.ts'; 12 | 13 | import { SHADER_PARAMS, SHADER_CODE } from './aoPass.wgsl.ts'; 14 | 15 | export class AoPass { 16 | public static NAME: string = 'AoPass'; 17 | 18 | private readonly pipeline: GPURenderPipeline; 19 | private readonly bindingsCache = new BindingsCache(); 20 | 21 | constructor(device: GPUDevice, outTextureFormat: GPUTextureFormat) { 22 | const shaderModule = device.createShaderModule({ 23 | label: labelShader(AoPass), 24 | code: SHADER_CODE(), 25 | }); 26 | this.pipeline = device.createRenderPipeline({ 27 | label: labelPipeline(AoPass), 28 | layout: 'auto', 29 | vertex: { 30 | module: shaderModule, 31 | entryPoint: 'main_vs', 32 | buffers: [], 33 | }, 34 | fragment: { 35 | module: shaderModule, 36 | entryPoint: 'main_fs', 37 | targets: [{ format: outTextureFormat }], 38 | }, 39 | primitive: { topology: 'triangle-list' }, 40 | }); 41 | } 42 | 43 | onViewportResize = () => this.bindingsCache.clear(); 44 | 45 | cmdCalcAo(ctx: PassCtx) { 46 | const { cmdBuf, profiler, aoTexture } = ctx; 47 | assertIsGPUTextureView(aoTexture); 48 | 49 | const renderPass = cmdBuf.beginRenderPass({ 50 | label: AoPass.NAME, 51 | colorAttachments: [ 52 | // TBH no need for clear as we override every pixel 53 | useColorAttachment(aoTexture, CONFIG.clearAo, 'clear'), 54 | ], 55 | timestampWrites: profiler?.createScopeGpu(AoPass.NAME), 56 | }); 57 | 58 | const bindings = this.bindingsCache.getBindings('-', () => 59 | this.createBindings(ctx) 60 | ); 61 | renderPass.setBindGroup(0, bindings); 62 | renderPass.setPipeline(this.pipeline); 63 | cmdDrawFullscreenTriangle(renderPass); 64 | 65 | // end 66 | renderPass.end(); 67 | } 68 | 69 | private createBindings = (ctx: PassCtx): GPUBindGroup => { 70 | const { 71 | device, 72 | globalUniforms, 73 | hdrRenderTexture, 74 | normalsTexture, 75 | depthTexture, 76 | } = ctx; 77 | const b = SHADER_PARAMS.bindings; 78 | assertIsGPUTextureView(hdrRenderTexture); 79 | 80 | return assignResourcesToBindings(AoPass, device, this.pipeline, [ 81 | globalUniforms.createBindingDesc(b.renderUniforms), 82 | { binding: b.depthTexture, resource: depthTexture }, 83 | { binding: b.normalsTexture, resource: normalsTexture }, 84 | ]); 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/passes/simulation/gridPostSimPass.ts: -------------------------------------------------------------------------------- 1 | import { HairObject } from '../../scene/hair/hairObject.ts'; 2 | import { getItemsPerThread } from '../../utils/webgpu.ts'; 3 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 4 | import { 5 | labelShader, 6 | labelPipeline, 7 | assignResourcesToBindings2, 8 | } from '../_shared/shared.ts'; 9 | import { PassCtx } from '../passCtx.ts'; 10 | import { SHADER_CODE, SHADER_PARAMS } from './gridPostSimPass.wgsl.ts'; 11 | 12 | export class GridPostSimPass { 13 | public static NAME: string = 'GridPostSimPass'; 14 | 15 | private readonly pipeline: GPUComputePipeline; 16 | private readonly bindingsCache = new BindingsCache(); 17 | 18 | constructor(device: GPUDevice) { 19 | const shaderModule = device.createShaderModule({ 20 | label: labelShader(GridPostSimPass), 21 | code: SHADER_CODE(), 22 | }); 23 | this.pipeline = device.createComputePipeline({ 24 | label: labelPipeline(GridPostSimPass), 25 | layout: 'auto', 26 | compute: { 27 | module: shaderModule, 28 | entryPoint: 'main', 29 | }, 30 | }); 31 | } 32 | 33 | cmdUpdateGridsAfterSim(ctx: PassCtx, hairObject: HairObject) { 34 | const { cmdBuf, profiler, physicsForcesGrid } = ctx; 35 | 36 | physicsForcesGrid.clearDensityVelocityBuffer(cmdBuf); 37 | 38 | const computePass = cmdBuf.beginComputePass({ 39 | label: GridPostSimPass.NAME, 40 | timestampWrites: profiler?.createScopeGpu(GridPostSimPass.NAME), 41 | }); 42 | 43 | const bindings = this.bindingsCache.getBindings( 44 | `${hairObject.name}-${hairObject.currentPositionsBufferIdx}`, 45 | () => this.createBindings(ctx, hairObject) 46 | ); 47 | computePass.setPipeline(this.pipeline); 48 | computePass.setBindGroup(0, bindings); 49 | 50 | // dispatch 51 | const workgroupsX = getItemsPerThread( 52 | hairObject.strandsCount, 53 | SHADER_PARAMS.workgroupSizeX 54 | ); 55 | // console.log(`${GridPostSimPass.NAME}.dispatch(${workgroupsX}, 1, 1)`); 56 | computePass.dispatchWorkgroups(workgroupsX, 1, 1); 57 | 58 | computePass.end(); 59 | } 60 | 61 | private createBindings = ( 62 | ctx: PassCtx, 63 | hairObject: HairObject 64 | ): GPUBindGroup => { 65 | const { device, simulationUniforms, physicsForcesGrid } = ctx; 66 | const b = SHADER_PARAMS.bindings; 67 | 68 | return assignResourcesToBindings2( 69 | GridPostSimPass, 70 | `${hairObject.name}-${hairObject.currentPositionsBufferIdx}`, 71 | device, 72 | this.pipeline, 73 | [ 74 | simulationUniforms.createBindingDesc(b.simulationUniforms), 75 | hairObject.bindHairData(b.hairData), 76 | hairObject.bindPointsPositions_PREV(b.positionsPrev), 77 | hairObject.bindPointsPositions(b.positionsNow), 78 | physicsForcesGrid.bindDensityVelocityBuffer(b.gridBuffer), 79 | ] 80 | ); 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /src/scene/sdfCollider/createMockSdfCollider.ts: -------------------------------------------------------------------------------- 1 | import { vec3 } from 'wgpu-matrix'; 2 | import { SDFCollider, SdfPoint3D } from './sdfCollider.ts'; 3 | import { BoundingBox } from '../../utils/bounds.ts'; 4 | import { createSDFTexture, createSdfSampler } from './sdfUtils.ts'; 5 | 6 | interface SdfCreateOpts { 7 | bounds: BoundingBox; 8 | /** How many points on each axis */ 9 | dims: number; 10 | } 11 | 12 | /** Mostly for visualization debugging */ 13 | export function createMockSdfCollider( 14 | device: GPUDevice, 15 | name: string, 16 | opts: SdfCreateOpts 17 | ): SDFCollider { 18 | const { bounds, dims: dims_ } = opts; 19 | const dims = Math.floor(dims_); 20 | if (dims < 2) { 21 | throw new Error(`Invalid SDF dims: ${dims_}`); 22 | } 23 | const segmentCount = dims - 1; // there is a better name in SDF terminology. Yet I've spend 2 weeks on hair rendering, so this is the name I'm gonna use. 24 | // console.log(opts); 25 | 26 | // calculate grid 27 | const [boundsMin, boundsMax] = bounds; 28 | const size = vec3.subtract(boundsMax, boundsMin); 29 | const cellSize = vec3.scale(size, 1.0 / segmentCount); 30 | /*console.log({ 31 | size, 32 | cellSize, 33 | entries: dims * dims * dims, 34 | bytes: dims * dims * dims * //BYTES_F32, 35 | });*/ 36 | 37 | // fill data 38 | const data = new Float32Array(dims * dims * dims); 39 | for (const [uvw, p] of generateSDFPoints(boundsMin, cellSize, dims)) { 40 | // uvw = values in [0, dim) 41 | // p = values in world space 42 | 43 | const mockCollider = [0, 1.4403254985809326, 0.008672002702951431]; 44 | // const mockCollider = [0, 1.3715709447860718, -0.0005870014429092407]; 45 | const radius = 0.05; 46 | 47 | const d = vec3.distance(p, mockCollider) - radius; 48 | // console.log(idx, uvw, ' -> ', p, ', d=', d); 49 | const idx = getIdx(dims, uvw); 50 | data[idx] = d; 51 | } 52 | 53 | // create texture 54 | // https://fynv.github.io/webgpu_test/client/volume_isosurface.html 55 | const tex = createSDFTexture(device, name, dims, data); 56 | const texView = tex.createView(); 57 | const sampler = createSdfSampler(device, name); 58 | 59 | return new SDFCollider(name, bounds, dims, tex, texView, sampler); 60 | } 61 | 62 | function* generateSDFPoints( 63 | boundsMin: SdfPoint3D, 64 | cellSize: SdfPoint3D, 65 | dim: number 66 | ) { 67 | for (let z = 0; z < dim; z++) { 68 | for (let y = 0; y < dim; y++) { 69 | for (let x = 0; x < dim; x++) { 70 | const p: SdfPoint3D = [ 71 | boundsMin[0] + cellSize[0] * x, 72 | boundsMin[1] + cellSize[1] * y, 73 | boundsMin[2] + cellSize[2] * z, 74 | ]; 75 | const uvw: SdfPoint3D = [x, y, z]; 76 | yield [uvw, p]; 77 | } 78 | } 79 | } 80 | } 81 | 82 | function getIdx(dim: number, p: SdfPoint3D) { 83 | return ( 84 | p[2] * dim * dim + // 85 | p[1] * dim + 86 | p[0] 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/passes/simulation/grids/grids.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../../constants.ts'; 2 | 3 | export const GRID_FLOAT_TO_U32_MUL = 1000000.0; 4 | 5 | export const GRID_UTILS = /* wgsl */ ` 6 | 7 | const GRID_DIMS: u32 = ${CONFIG.hairSimulation.physicsForcesGrid.dims}u; 8 | 9 | // There are no float atomics in WGSL. Convert to i32 10 | const GRID_FLOAT_TO_U32_MUL: f32 = ${GRID_FLOAT_TO_U32_MUL}; 11 | fn gridEncodeValue(v: f32) -> i32 { return i32(v * GRID_FLOAT_TO_U32_MUL); } 12 | fn gridDecodeValue(v: i32) -> f32 { return f32(v) / GRID_FLOAT_TO_U32_MUL; } 13 | 14 | 15 | fn _getGridIdx(p: vec3u) -> u32 { 16 | return ( 17 | clamp(p.z, 0u, GRID_DIMS - 1u) * GRID_DIMS * GRID_DIMS + 18 | clamp(p.y, 0u, GRID_DIMS - 1u) * GRID_DIMS + 19 | clamp(p.x, 0u, GRID_DIMS - 1u) 20 | ); 21 | } 22 | 23 | fn getGridCellSize(gridBoundsMin: vec3f, gridBoundsMax: vec3f) -> vec3f { 24 | let size = gridBoundsMax - gridBoundsMin; 25 | return size / f32(GRID_DIMS - 1u); 26 | } 27 | 28 | /** Take (0,1,4) grid point and turn into vec3f coords */ 29 | fn getGridPointPositionWS( 30 | gridBoundsMin: vec3f, 31 | gridBoundsMax: vec3f, 32 | p: vec3u 33 | ) -> vec3f { 34 | let cellSize = getGridCellSize(gridBoundsMin, gridBoundsMax); 35 | return gridBoundsMin + cellSize * vec3f(p); 36 | } 37 | 38 | fn getClosestGridPoint( 39 | gridBoundsMin: vec3f, 40 | gridBoundsMax: vec3f, 41 | p: vec3f 42 | ) -> vec3u { 43 | var t: vec3f = saturate( 44 | (p - gridBoundsMin) / (gridBoundsMax - gridBoundsMin) 45 | ); 46 | var r: GridCoordinates; 47 | return vec3u(round(t * f32(GRID_DIMS - 1u))); 48 | } 49 | 50 | struct GridCoordinates { 51 | // XYZ of the 'lower' cell-cube corner. E.g [0, 1, 2] 52 | cellMin: vec3u, 53 | // XYZ of the 'upper' cell-cube corner. E.g [1, 2, 3] 54 | // Effectively $cellMin + (1,1,1)$ 55 | cellMax: vec3u, 56 | // provided point $p in grid coordinates. E.g. [1.1, 2.4, 3.4] 57 | pInGrid: vec3f 58 | } 59 | 60 | 61 | fn _getGridCell( 62 | gridBoundsMin: vec3f, 63 | gridBoundsMax: vec3f, 64 | p: vec3f, 65 | ) -> GridCoordinates { 66 | var t: vec3f = saturate( 67 | (p - gridBoundsMin) / (gridBoundsMax - gridBoundsMin) 68 | ); 69 | var r: GridCoordinates; 70 | r.pInGrid = t * f32(GRID_DIMS - 1u); 71 | r.cellMin = vec3u(floor(t * f32(GRID_DIMS - 1u))); 72 | r.cellMax = vec3u( ceil(t * f32(GRID_DIMS - 1u))); 73 | return r; 74 | } 75 | 76 | fn _getGridCellWeights( 77 | cellCornerCo: vec3u, 78 | originalPoint: vec3f, 79 | ) -> vec3f { 80 | let w_x = clamp(1.0 - f32(abs(originalPoint.x - f32(cellCornerCo.x))), 0.0, 1.0); 81 | let w_y = clamp(1.0 - f32(abs(originalPoint.y - f32(cellCornerCo.y))), 0.0, 1.0); 82 | let w_z = clamp(1.0 - f32(abs(originalPoint.z - f32(cellCornerCo.z))), 0.0, 1.0); 83 | return vec3f(w_x, w_y, w_z); 84 | } 85 | 86 | /** 87 | * Compress '_getGridCellWeights()' into a single value. Used when stored value is not a vector. 88 | * Not amazing, but.. 89 | */ 90 | fn _getGridCellWeight(cellW: vec3f) -> f32 { 91 | return length(cellW); 92 | } 93 | `; 94 | -------------------------------------------------------------------------------- /src/passes/simulation/gridPreSimPass.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../constants.ts'; 2 | import { HairObject } from '../../scene/hair/hairObject.ts'; 3 | import { getItemsPerThread } from '../../utils/webgpu.ts'; 4 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 5 | import { 6 | labelShader, 7 | labelPipeline, 8 | assignResourcesToBindings2, 9 | } from '../_shared/shared.ts'; 10 | import { PassCtx } from '../passCtx.ts'; 11 | import { SHADER_CODE, SHADER_PARAMS } from './gridPreSimPass.wgsl.ts'; 12 | 13 | export class GridPreSimPass { 14 | public static NAME: string = 'GridPreSimPass'; 15 | 16 | private readonly pipeline: GPUComputePipeline; 17 | private readonly bindingsCache = new BindingsCache(); 18 | 19 | constructor(device: GPUDevice) { 20 | const shaderModule = device.createShaderModule({ 21 | label: labelShader(GridPreSimPass), 22 | code: SHADER_CODE(), 23 | }); 24 | this.pipeline = device.createComputePipeline({ 25 | label: labelPipeline(GridPreSimPass), 26 | layout: 'auto', 27 | compute: { 28 | module: shaderModule, 29 | entryPoint: 'main', 30 | }, 31 | }); 32 | } 33 | 34 | cmdUpdateGridsBeforeSim(ctx: PassCtx, hairObject: HairObject) { 35 | const { cmdBuf, profiler, physicsForcesGrid } = ctx; 36 | 37 | physicsForcesGrid.clearDensityGradAndWindBuffer(cmdBuf); 38 | 39 | const computePass = cmdBuf.beginComputePass({ 40 | label: GridPreSimPass.NAME, 41 | timestampWrites: profiler?.createScopeGpu(GridPreSimPass.NAME), 42 | }); 43 | 44 | const bindings = this.bindingsCache.getBindings( 45 | `${hairObject.name}-${hairObject.currentPositionsBufferIdx}`, 46 | () => this.createBindings(ctx, hairObject) 47 | ); 48 | computePass.setPipeline(this.pipeline); 49 | computePass.setBindGroup(0, bindings); 50 | 51 | // dispatch 52 | const gridDims = CONFIG.hairSimulation.physicsForcesGrid.dims; 53 | const threadsTotal = gridDims * gridDims * gridDims; 54 | const workgroupsX = getItemsPerThread( 55 | threadsTotal, 56 | SHADER_PARAMS.workgroupSizeX 57 | ); 58 | // console.log(`${GridPreSimPass.NAME}.dispatch(${workgroupsX}, 1, 1)`); 59 | computePass.dispatchWorkgroups(workgroupsX, 1, 1); 60 | 61 | computePass.end(); 62 | } 63 | 64 | private createBindings = ( 65 | ctx: PassCtx, 66 | hairObject: HairObject 67 | ): GPUBindGroup => { 68 | const { device, simulationUniforms, physicsForcesGrid, scene } = ctx; 69 | const b = SHADER_PARAMS.bindings; 70 | const sdf = scene.sdfCollider; 71 | 72 | return assignResourcesToBindings2( 73 | GridPreSimPass, 74 | `${hairObject.name}-${hairObject.currentPositionsBufferIdx}`, 75 | device, 76 | this.pipeline, 77 | [ 78 | simulationUniforms.createBindingDesc(b.simulationUniforms), 79 | physicsForcesGrid.bindDensityVelocityBuffer(b.densityVelocityBuffer), 80 | physicsForcesGrid.bindDensityGradAndWindBuffer(b.densityGradWindBuffer), 81 | sdf.bindTexture(b.sdfTexture), 82 | sdf.bindSampler(b.sdfSampler), 83 | ] 84 | ); 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/passes/_shared/shared.ts: -------------------------------------------------------------------------------- 1 | import { ClearColor, DEPTH_FORMAT } from '../../constants.ts'; 2 | import { assertIsGPUTextureView } from '../../utils/webgpu.ts'; 3 | 4 | type PassClass = { NAME: string }; 5 | 6 | export const createLabel = (pass: PassClass, name = '') => 7 | `${pass.NAME}${name ? '-' + name : ''}`; 8 | 9 | export const labelShader = (pass: PassClass, name = '') => 10 | `${createLabel(pass, name)}-shader`; 11 | export const labelPipeline = (pass: PassClass, name = '') => 12 | `${createLabel(pass, name)}-pipeline`; 13 | export const labelUniformBindings = (pass: PassClass, name = '') => 14 | `${createLabel(pass, name)}-uniforms`; 15 | 16 | export const PIPELINE_PRIMITIVE_TRIANGLE_LIST: GPUPrimitiveState = { 17 | cullMode: 'back', 18 | topology: 'triangle-list', 19 | stripIndexFormat: undefined, 20 | }; 21 | 22 | export const PIPELINE_DEPTH_ON: GPUDepthStencilState = { 23 | format: DEPTH_FORMAT, 24 | depthWriteEnabled: true, 25 | depthCompare: 'less', 26 | }; 27 | 28 | export const assignResourcesToBindings = ( 29 | pass: PassClass, 30 | device: GPUDevice, 31 | pipeline: GPURenderPipeline | GPUComputePipeline, 32 | entries: GPUBindGroupEntry[] 33 | ) => { 34 | return assignResourcesToBindings2(pass, '', device, pipeline, entries); 35 | }; 36 | 37 | export const assignResourcesToBindings2 = ( 38 | pass: PassClass, 39 | name: string, 40 | device: GPUDevice, 41 | pipeline: GPURenderPipeline | GPUComputePipeline, 42 | entries: GPUBindGroupEntry[] 43 | ) => { 44 | const uniformsLayout = pipeline.getBindGroupLayout(0); 45 | return device.createBindGroup({ 46 | label: labelUniformBindings(pass, name), 47 | layout: uniformsLayout, 48 | entries, 49 | }); 50 | }; 51 | 52 | export const useColorAttachment = ( 53 | colorTexture: GPUTextureView, 54 | clearColor: ClearColor, 55 | loadOp: GPULoadOp, 56 | storeOp: GPUStoreOp = 'store' 57 | ): GPURenderPassColorAttachment => { 58 | assertIsGPUTextureView(colorTexture); 59 | return { 60 | view: colorTexture, 61 | loadOp, 62 | storeOp, 63 | clearValue: clearColor, 64 | }; 65 | }; 66 | 67 | export const useDepthStencilAttachment = ( 68 | depthTexture: GPUTextureView, 69 | depthLoadOp: GPULoadOp, 70 | depthStoreOp: GPUStoreOp = 'store' 71 | ): GPURenderPassDepthStencilAttachment => { 72 | assertIsGPUTextureView(depthTexture); 73 | return { 74 | view: depthTexture, 75 | depthClearValue: 1.0, 76 | depthLoadOp, 77 | depthStoreOp, 78 | }; 79 | }; 80 | 81 | // TODO [LOW] use everywhere 82 | export const createComputePipeline = ( 83 | device: GPUDevice, 84 | passClass: PassClass, 85 | shaderText: string, 86 | name = '', 87 | mainFn = 'main' 88 | ): GPUComputePipeline => { 89 | const shaderModule = device.createShaderModule({ 90 | label: labelShader(passClass, name), 91 | code: shaderText, 92 | }); 93 | return device.createComputePipeline({ 94 | label: labelPipeline(passClass, name), 95 | layout: 'auto', 96 | compute: { 97 | module: shaderModule, 98 | entryPoint: mainFn, 99 | }, 100 | }); 101 | }; 102 | -------------------------------------------------------------------------------- /src/passes/swHair/shared/hairSlicesDataBuffer.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_U32, CONFIG } from '../../../constants.ts'; 2 | import { STATS } from '../../../stats.ts'; 3 | import { formatBytes } from '../../../utils/string.ts'; 4 | import { WEBGPU_MINIMAL_BUFFER_SIZE } from '../../../utils/webgpu.ts'; 5 | 6 | /////////////////////////// 7 | /// SHADER CODE 8 | /// 9 | /// We also have to split whole memory into per-processor subregions 10 | /// as the free() is impossible to implement otherwise. 11 | /// Once processor moves to another tile, all memory alloc. 12 | /// from previous tile does not matter. 13 | /////////////////////////// 14 | 15 | const cfgHair = CONFIG.hairRender; 16 | const SLICE_DATA_PER_PROCESSOR_COUNT = 17 | cfgHair.avgFragmentsPerSlice * 18 | cfgHair.slicesPerPixel * 19 | cfgHair.tileSize * 20 | cfgHair.tileSize; 21 | 22 | /** 23 | * Memory pool for slice data. Each processor contains own subregion 24 | * to make it easier to free() the memory between tiles. Each slice 25 | * data contains color and a pointer to next entry. 26 | */ 27 | export const BUFFER_HAIR_SLICES_DATA = ( 28 | bindingIdx: number, 29 | access: 'read_write' 30 | ) => /* wgsl */ ` 31 | 32 | const SLICE_DATA_PER_PROCESSOR_COUNT = ${SLICE_DATA_PER_PROCESSOR_COUNT}u; 33 | 34 | struct SliceData { 35 | /** [encodedColor.rg, encodedColor.ba, nextSlicePtr, 0u] */ 36 | value: vec4u, 37 | } 38 | 39 | @group(0) @binding(${bindingIdx}) 40 | var _hairSliceData: array; 41 | 42 | fn _getSliceDataProcessorOffset(processorId: u32) -> u32 { 43 | return processorId * SLICE_DATA_PER_PROCESSOR_COUNT; 44 | } 45 | 46 | fn _hasMoreSliceDataSlots(slicePtr: u32) -> bool { 47 | return slicePtr < SLICE_DATA_PER_PROCESSOR_COUNT; 48 | } 49 | 50 | fn _setSliceData( 51 | processorId: u32, 52 | slicePtr: u32, 53 | color: vec4f, previousPtr: u32 // both are data to be written 54 | ) { 55 | let offset = _getSliceDataProcessorOffset(processorId) + slicePtr; 56 | let value = vec4u( 57 | pack2x16float(color.rg), 58 | pack2x16float(color.ba), 59 | previousPtr, 60 | 0u 61 | ); 62 | _hairSliceData[offset].value = value; 63 | } 64 | 65 | fn _getSliceData( 66 | processorId: u32, 67 | slicePtr: u32, 68 | data: ptr 69 | ) -> bool { 70 | if ( 71 | slicePtr == INVALID_SLICE_DATA_PTR || 72 | slicePtr >= SLICE_DATA_PER_PROCESSOR_COUNT 73 | ) { return false; } 74 | 75 | let offset = _getSliceDataProcessorOffset(processorId) + slicePtr; 76 | (*data).value = _hairSliceData[offset].value; 77 | return true; 78 | } 79 | `; 80 | 81 | /////////////////////////// 82 | /// GPU BUFFER 83 | /////////////////////////// 84 | 85 | export function createHairSlicesDataBuffer(device: GPUDevice): GPUBuffer { 86 | const { processorCount } = CONFIG.hairRender; 87 | const entries = SLICE_DATA_PER_PROCESSOR_COUNT * processorCount; 88 | const bytesPerEntry = 4 * BYTES_U32; 89 | const size = Math.max(entries * bytesPerEntry, WEBGPU_MINIMAL_BUFFER_SIZE); 90 | STATS.update('Slices data', formatBytes(size)); 91 | 92 | return device.createBuffer({ 93 | label: `hair-slices-data`, 94 | size, 95 | usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST, 96 | }); 97 | } 98 | -------------------------------------------------------------------------------- /scripts/generate_sdf/src/main.rs: -------------------------------------------------------------------------------- 1 | 2 | use std::{ 3 | path::{Path}, 4 | io::{Write}, 5 | fs::File, 6 | }; 7 | use mesh_to_sdf::{generate_grid_sdf, SignMethod, Topology, Grid}; 8 | 9 | 10 | fn main() { 11 | // let path = Path::new("../../static/models/sdf-test.obj"); 12 | let path = Path::new("../../static/models/sintel-collider.obj"); 13 | let dims: usize = 64; 14 | let padding = 1.30; 15 | 16 | /////////////// 17 | let obj = tobj::load_obj( 18 | path, 19 | &tobj::LoadOptions { 20 | // triangulate: true, 21 | // single_index: true, 22 | ..Default::default() 23 | }, 24 | ); 25 | assert!(obj.is_ok()); 26 | let (models, _materials) = obj.unwrap(); 27 | 28 | assert!(models.len() == 1); 29 | let mesh = &models[0].mesh; 30 | // println!("{:?}", mesh); 31 | 32 | let mut vertices: Vec<[f32; 3]> = Vec::new(); 33 | let vert_cnt = mesh.positions.len() / 3; 34 | 35 | // calculate bounding box 36 | let mut bounds_min = [mesh.positions[0], mesh.positions[1], mesh.positions[2]]; 37 | let mut bounds_max = [mesh.positions[0], mesh.positions[1], mesh.positions[2]]; 38 | for index in 0..vert_cnt { 39 | let p = [ 40 | mesh.positions[index * 3], 41 | mesh.positions[index * 3 + 1], 42 | mesh.positions[index * 3 + 2], 43 | ]; 44 | for co in 0..3 { 45 | bounds_min[co] = bounds_min[co].min(p[co]); 46 | bounds_max[co] = bounds_max[co].max(p[co]); 47 | } 48 | vertices.push(p); 49 | } 50 | 51 | // add padding 52 | for co in 0..3 { 53 | let center = (bounds_min[co] + bounds_max[co]) / 2.0; 54 | let delta = padding * (bounds_max[co] - center); 55 | bounds_min[co] = center - delta; 56 | bounds_max[co] = center + delta; 57 | } 58 | 59 | 60 | // if you can, use generate_grid_sdf instead of generate_sdf as it's optimized and much faster. 61 | let cell_count = [dims, dims, dims]; 62 | 63 | let grid = Grid::from_bounding_box(&bounds_min, &bounds_max, cell_count); 64 | 65 | let sdf: Vec = generate_grid_sdf( 66 | &vertices, 67 | Topology::TriangleList(Some(&mesh.indices)), 68 | &grid, 69 | SignMethod::Raycast // Normal/Raycast 70 | ); 71 | 72 | let mut file = File::create("sintel-sdf.bin").unwrap(); 73 | // let cursor = Cursor::new(&mut f); 74 | // cursor.write_i32::(10).unwrap(); 75 | // cursor.write_i32(10.to_le_bytes()).unwrap(); 76 | file.write_all(&dims.to_le_bytes()).unwrap(); 77 | file.write_all(&bounds_min[0].to_le_bytes()).unwrap(); 78 | file.write_all(&bounds_min[1].to_le_bytes()).unwrap(); 79 | file.write_all(&bounds_min[2].to_le_bytes()).unwrap(); 80 | file.write_all(&bounds_max[0].to_le_bytes()).unwrap(); 81 | file.write_all(&bounds_max[1].to_le_bytes()).unwrap(); 82 | file.write_all(&bounds_max[2].to_le_bytes()).unwrap(); 83 | 84 | 85 | for z in 0..cell_count[2] { 86 | for y in 0..cell_count[1] { 87 | for x in 0..cell_count[0] { 88 | let index = grid.get_cell_idx(&[x, y, z]); 89 | let value = sdf[index as usize]; 90 | // println!("Distance to cell [{}, {}, {}]: {}", x, y, z, value); 91 | // println!("{}", value); 92 | file.write_all(&value.to_le_bytes()).unwrap(); 93 | } 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /src/passes/swHair/shaderImpl/processHairSegment.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../../constants.ts'; 2 | 3 | export const SHADER_IMPL_PROCESS_HAIR_SEGMENT = () => /* wgsl */ ` 4 | 5 | fn processHairSegment( 6 | params: FineRasterParams, 7 | tileBoundsPx: vec4u, tileDepth: vec2f, 8 | strandIdx: u32, segmentIdx: u32 9 | ) { 10 | // get pixel coordinates 11 | // Half-of-the-pixel offset not added as it causes problems (small random pixels around the strand) 12 | // https://www.sctheblog.com/blog/hair-software-rasterize/#half-of-the-pixel-offset 13 | let posPx = vec2f(tileBoundsPx.xy + _pixelInTilePos); // pixel coordinates wrt. viewport 14 | let CX0 = edgeFunction(_wkgrp_hairSegment.v01, _wkgrp_hairSegment.v00, posPx); 15 | let CX1 = edgeFunction(_wkgrp_hairSegment.v11, _wkgrp_hairSegment.v01, posPx); 16 | let CX2 = edgeFunction(_wkgrp_hairSegment.v10, _wkgrp_hairSegment.v11, posPx); 17 | let CX3 = edgeFunction(_wkgrp_hairSegment.v00, _wkgrp_hairSegment.v10, posPx); 18 | 19 | let isOutside = CX0 < 0 || CX1 < 0 || CX2 < 0 || CX3 < 0; 20 | if (isOutside) { 21 | return; 22 | } 23 | 24 | // https://www.sctheblog.com/blog/hair-software-rasterize/#segment-space-coordinates 25 | let interpW = interpolateHairQuad(_wkgrp_hairSegment, posPx); 26 | let t = interpW.y; // 0..1 wrt. to hair segment length: 0 is start, 1 is end 27 | let hairDepth: f32 = interpolateHairF32(interpW, _wkgrp_hairSegment.depthsProj); 28 | 29 | // sample depth buffer, depth test with GL_LESS 30 | let depthTextSamplePx: vec2i = vec2i(i32(posPx.x), i32(params.viewportSize.y - posPx.y)); // wgpu's naga requires vec2i.. 31 | let depthBufferValue: f32 = textureLoad(_depthTexture, depthTextSamplePx, 0); 32 | if (hairDepth >= depthBufferValue) { 33 | return; 34 | } 35 | 36 | // allocate data pointer 37 | let nextSliceDataPtr = atomicAdd(&_wkgrp.sliceDataOffset, 1u); 38 | if (!_hasMoreSliceDataSlots(nextSliceDataPtr)) { 39 | return; 40 | } 41 | 42 | // calculate final color 43 | let alpha = getAlphaCoverage(interpW); 44 | let segmentCount = params.pointsPerStrand - 1; 45 | let tFullStrand = (f32(segmentIdx) + t) / f32(segmentCount); 46 | // var color = vec4f(1.0 - t, t, 0.0, alpha); // dbg: red at root, green at tip 47 | var color = _sampleShading(strandIdx, tFullStrand); 48 | color.a = color.a * alpha; 49 | 50 | // insert into per-slice linked list 51 | let sliceIdx = getSliceIdx(tileDepth, hairDepth); 52 | let previousPtr: u32 = _setSlicesHeadPtr(params.processorId, _pixelInTilePos, sliceIdx, nextSliceDataPtr); 53 | _setSliceData(params.processorId, nextSliceDataPtr, color, previousPtr); 54 | } 55 | 56 | fn getSliceIdx(tileDepth: vec2f, pixelDepth: f32) -> u32 { 57 | // reuse fn. Ignore the name 58 | return getDepthBin(SLICES_PER_PIXEL, tileDepth, pixelDepth); 59 | } 60 | 61 | fn getAlphaCoverage(interpW: vec2f) -> f32 { 62 | // interpW.x is in 0..1. Transform it so strand middle is 1.0 and then 0.0 at edges. 63 | var alpha = 1.0 - abs(interpW.x * 2. - 1.); 64 | if (${CONFIG.hairRender.alphaQuadratic}) { // see CONFIG docs 65 | alpha = sqrt(alpha); 66 | } 67 | // optimization: -0.5ms with x1.1 'fatter' strands. Fills the pixel/tiles faster 68 | alpha = saturate(alpha * ${CONFIG.hairRender.alphaMultipler}); 69 | return alpha; 70 | } 71 | 72 | `; 73 | -------------------------------------------------------------------------------- /src/scene/sdfCollider/sdfCollider.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_VEC4, CONFIG } from '../../constants.ts'; 2 | import { TypedArrayView } from '../../utils/typedArrayView.ts'; 3 | import { BoundingBox, BoundingBoxPoint } from '../../utils/bounds.ts'; 4 | import { assertIsGPUTextureView } from '../../utils/webgpu.ts'; 5 | import { vec3 } from 'wgpu-matrix'; 6 | 7 | export type SdfPoint3D = BoundingBoxPoint; 8 | 9 | export class SDFCollider { 10 | public static SDF_DATA_SNIPPET = /* wgsl */ ` 11 | struct SDFCollider { 12 | boundsMin: vec4f, 13 | boundsMax: vec4f, 14 | }; 15 | 16 | fn getSdfDebugDepthSlice() -> f32 { 17 | var s = _uniforms.sdf.boundsMin.w; 18 | if (s > 1.0) { return s - 2.0; } 19 | return s; 20 | } 21 | fn isSdfDebugSemiTransparent() -> bool { return _uniforms.sdf.boundsMin.w > 1.0; } 22 | fn getSDF_Offset() -> f32 { return _uniforms.sdf.boundsMax.w; } 23 | `; 24 | public static BUFFER_SIZE = 2 * BYTES_VEC4; 25 | 26 | public static TEXTURE_SDF = ( 27 | bindingIdx: number, 28 | samplerBindingIdx: number 29 | ) => /* wgsl */ ` 30 | @group(0) @binding(${bindingIdx}) 31 | var _sdfTexture: texture_3d; 32 | 33 | @group(0) @binding(${samplerBindingIdx}) 34 | var _sdfSampler: sampler; 35 | 36 | fn sampleSDFCollider(sdfBoundsMin: vec3f, sdfBoundsMax: vec3f, p: vec3f) -> f32 { 37 | // TBH bounds can be as bound sphere if mesh is cube-ish in shape 38 | var t: vec3f = saturate( 39 | (p - sdfBoundsMin) / (sdfBoundsMax - sdfBoundsMin) 40 | ); 41 | // t.y = 1.0 - t.y; // WebGPU reverted Y-axis 42 | // t.z = 1.0 - t.z; // WebGPU reverted Z-axis (I guess?) 43 | return textureSampleLevel(_sdfTexture, _sdfSampler, t, 0.0).x; 44 | } 45 | `; 46 | 47 | constructor( 48 | public readonly name: string, 49 | public readonly bounds: BoundingBox, 50 | public readonly dims: number, 51 | private readonly texture: GPUTexture, 52 | private readonly textureView: GPUTextureView, 53 | private readonly sampler: GPUSampler 54 | ) { 55 | if (!CONFIG.isTest) { 56 | assertIsGPUTextureView(textureView); 57 | } 58 | 59 | const [boundsMin, boundsMax] = bounds; 60 | const size = vec3.subtract(boundsMax, boundsMin); 61 | const cellSize = vec3.scale(size, 1 / (dims - 1)); 62 | if (!CONFIG.isTest) { 63 | console.log(`SDF collider '${name}' (dims=${dims}, cellSize=${cellSize}), bounds:`, bounds); // prettier-ignore 64 | } 65 | } 66 | 67 | bindTexture = (bindingIdx: number): GPUBindGroupEntry => ({ 68 | binding: bindingIdx, 69 | resource: this.textureView, 70 | }); 71 | 72 | bindSampler = (bindingIdx: number): GPUBindGroupEntry => ({ 73 | binding: bindingIdx, 74 | resource: this.sampler, 75 | }); 76 | 77 | writeToDataView(dataView: TypedArrayView) { 78 | const c = CONFIG.hairSimulation.sdf; 79 | const [boundsMin, boundsMax] = this.bounds; 80 | 81 | dataView.writeF32(boundsMin[0]); 82 | dataView.writeF32(boundsMin[1]); 83 | dataView.writeF32(boundsMin[2]); 84 | const mod = c.debugSemitransparent ? 2 : 0; 85 | dataView.writeF32(c.debugSlice + mod); 86 | 87 | dataView.writeF32(boundsMax[0]); 88 | dataView.writeF32(boundsMax[1]); 89 | dataView.writeF32(boundsMax[2]); 90 | dataView.writeF32(c.distanceOffset); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/passes/swHair/shared/hairTileSegmentsBuffer.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_U32, CONFIG } from '../../../constants.ts'; 2 | import { 3 | StorageAccess, 4 | WEBGPU_MINIMAL_BUFFER_SIZE, 5 | u32_type, 6 | } from '../../../utils/webgpu.ts'; 7 | import { getTileCount } from './utils.ts'; 8 | import { Dimensions } from '../../../utils/index.ts'; 9 | import { STATS } from '../../../stats.ts'; 10 | import { formatBytes } from '../../../utils/string.ts'; 11 | 12 | /* 13 | https://webgpu.github.io/webgpu-samples/?sample=a-buffer#translucent.wgsl 14 | */ 15 | 16 | /////////////////////////// 17 | /// SHADER CODE 18 | /////////////////////////// 19 | 20 | const storeTileSegment = /* wgsl */ ` 21 | 22 | fn _storeTileSegment( 23 | nextPtr: u32, prevPtr: u32, 24 | strandIdx: u32, segmentIdx: u32 25 | ) { 26 | let encodedSegment = (segmentIdx << 24) | strandIdx; 27 | _hairTileSegments.data[nextPtr].strandAndSegmentIdxs = encodedSegment; 28 | _hairTileSegments.data[nextPtr].next = prevPtr; 29 | } 30 | `; 31 | 32 | const getTileSegment = /* wgsl */ ` 33 | 34 | fn _getTileSegment( 35 | maxDrawnSegments: u32, 36 | tileSegmentPtr: u32, 37 | /** strandIdx, segmentIdx, nextPtr */ 38 | result: ptr 39 | ) -> bool { 40 | if (tileSegmentPtr >= maxDrawnSegments || tileSegmentPtr == INVALID_TILE_SEGMENT_PTR) { 41 | return false; 42 | } 43 | 44 | let data = _hairTileSegments.data[tileSegmentPtr]; 45 | (*result).x = data.strandAndSegmentIdxs & 0x00ffffff; 46 | (*result).y = data.strandAndSegmentIdxs >> 24; 47 | (*result).z = data.next; 48 | return true; 49 | } 50 | `; 51 | 52 | /** 53 | * Per-tile linked list of packed (strandId, segmentId). 54 | */ 55 | export const BUFFER_HAIR_TILE_SEGMENTS = ( 56 | bindingIdx: number, 57 | access: StorageAccess 58 | ) => /* wgsl */ ` 59 | 60 | struct LinkedListElement { 61 | strandAndSegmentIdxs: u32, 62 | next: u32 63 | }; 64 | 65 | struct DrawnHairSegments { 66 | drawnSegmentsCount: ${u32_type(access)}, 67 | data: array 68 | }; 69 | 70 | @group(0) @binding(${bindingIdx}) 71 | var _hairTileSegments: DrawnHairSegments; 72 | 73 | 74 | ${access == 'read_write' ? storeTileSegment : getTileSegment} 75 | `; 76 | 77 | /////////////////////////// 78 | /// GPU BUFFER 79 | /////////////////////////// 80 | 81 | export function getLengthOfHairTileSegmentsBuffer(viewportSize: Dimensions) { 82 | const tileCount = getTileCount(viewportSize); 83 | const cnt = Math.ceil( 84 | tileCount.width * tileCount.height * CONFIG.hairRender.avgSegmentsPerTile 85 | ); 86 | return 1 + cnt; 87 | } 88 | 89 | export function createHairTileSegmentsBuffer( 90 | device: GPUDevice, 91 | viewportSize: Dimensions 92 | ): GPUBuffer { 93 | const entries = getLengthOfHairTileSegmentsBuffer(viewportSize); 94 | const bytesPerEntry = 2 * BYTES_U32; 95 | const size = Math.max(entries * bytesPerEntry, WEBGPU_MINIMAL_BUFFER_SIZE); 96 | STATS.update('Tiles segments', formatBytes(size)); 97 | 98 | const extraUsage = CONFIG.isTest ? GPUBufferUsage.COPY_SRC : 0; // for stats, debug etc. 99 | return device.createBuffer({ 100 | label: `hair-segments-per-tile`, 101 | size, 102 | usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | extraUsage, 103 | }); 104 | } 105 | -------------------------------------------------------------------------------- /src/passes/hwHair/hwHairPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { BUFFER_HAIR_DATA } from '../../scene/hair/hairDataBuffer.ts'; 2 | import { BUFFER_HAIR_POINTS_POSITIONS } from '../../scene/hair/hairPointsPositionsBuffer.ts'; 3 | import { BUFFER_HAIR_SHADING } from '../../scene/hair/hairShadingBuffer.ts'; 4 | import { BUFFER_HAIR_TANGENTS } from '../../scene/hair/hairTangentsBuffer.ts'; 5 | import * as SHADER_SNIPPETS from '../_shaderSnippets/shaderSnippets.wgls.ts'; 6 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 7 | import { HW_RASTERIZE_HAIR } from './shaderImpl/hwRasterizeHair.wgsl.ts'; 8 | 9 | export const SHADER_PARAMS = { 10 | bindings: { 11 | renderUniforms: 0, 12 | hairPositions: 1, 13 | hairTangents: 2, 14 | hairData: 3, 15 | hairShading: 4, 16 | }, 17 | }; 18 | 19 | /////////////////////////// 20 | /// SHADER CODE 21 | /////////////////////////// 22 | const b = SHADER_PARAMS.bindings; 23 | 24 | export const SHADER_CODE = () => /* wgsl */ ` 25 | 26 | ${SHADER_SNIPPETS.GET_MVP_MAT} 27 | ${SHADER_SNIPPETS.NORMALS_UTILS} 28 | ${SHADER_SNIPPETS.GENERIC_UTILS} 29 | ${HW_RASTERIZE_HAIR} 30 | 31 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 32 | ${BUFFER_HAIR_POINTS_POSITIONS(b.hairPositions)} 33 | ${BUFFER_HAIR_TANGENTS(b.hairTangents)} 34 | ${BUFFER_HAIR_DATA(b.hairData)} 35 | ${BUFFER_HAIR_SHADING(b.hairShading, 'read')} 36 | 37 | 38 | struct VertexOutput { 39 | @builtin(position) position: vec4, 40 | @location(0) tangentWS: vec4f, 41 | @location(1) @interpolate(flat) strandIdx: u32, 42 | @location(2) tFullStrand: f32, 43 | }; 44 | 45 | 46 | @vertex 47 | fn main_vs( 48 | @builtin(vertex_index) inVertexIndex : u32 49 | ) -> VertexOutput { 50 | let hwRasterParams = HwHairRasterizeParams( 51 | _uniforms.modelViewMat, 52 | _uniforms.projMatrix, 53 | _uniforms.fiberRadius, 54 | inVertexIndex 55 | ); 56 | let hwRasterResult = hwRasterizeHair(hwRasterParams); 57 | 58 | let strandData = getHairStrandData( 59 | _hairData.pointsPerStrand, 60 | inVertexIndex 61 | ); 62 | 63 | var result: VertexOutput; 64 | result.position = hwRasterResult.position; 65 | result.tangentWS = _uniforms.modelMatrix * vec4f(hwRasterResult.tangentOBJ, 1.); 66 | result.strandIdx = strandData.strandIdx; 67 | result.tFullStrand = strandData.tFullStrand; 68 | return result; 69 | } 70 | 71 | 72 | struct FragmentOutput { 73 | @location(0) color: vec4, 74 | @location(1) normals: vec2, 75 | }; 76 | 77 | @fragment 78 | fn main_fs(fragIn: VertexOutput) -> FragmentOutput { 79 | let displayMode = getDisplayMode(); 80 | 81 | var result: FragmentOutput; 82 | // result.color = vec4f(1.0, 0.0, 0.0, 1.0); 83 | // let c = 0.4; 84 | // result.color = vec4f(c, c, c, 1.0); 85 | 86 | if (displayMode == DISPLAY_MODE_HW_RENDER) { 87 | var color = _sampleShading(fragIn.strandIdx, fragIn.tFullStrand); 88 | result.color = vec4f(color.rgb, 1.0); 89 | // dbg: gradient root -> tip 90 | // result.color = mix(vec4f(1., 0., 0., 1.0), vec4f(0., 0., 1., 1.0), fragIn.tFullStrand); 91 | } else { 92 | result.color.a = 0.0; 93 | } 94 | 95 | let tangent = normalize(fragIn.tangentWS.xyz); 96 | result.normals = encodeOctahedronNormal(tangent); 97 | 98 | return result; 99 | } 100 | `; 101 | -------------------------------------------------------------------------------- /src/passes/simulation/hairSimIntegrationPass.ts: -------------------------------------------------------------------------------- 1 | import { HairObject } from '../../scene/hair/hairObject.ts'; 2 | import { getItemsPerThread } from '../../utils/webgpu.ts'; 3 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 4 | import { 5 | labelShader, 6 | labelPipeline, 7 | assignResourcesToBindings2, 8 | } from '../_shared/shared.ts'; 9 | import { PassCtx } from '../passCtx.ts'; 10 | import { SHADER_CODE, SHADER_PARAMS } from './hairSimIntegrationPass.wgsl.ts'; 11 | 12 | export class HairSimIntegrationPass { 13 | public static NAME: string = 'HairSimIntegrationPass'; 14 | 15 | private readonly pipeline: GPUComputePipeline; 16 | private readonly bindingsCache = new BindingsCache(); 17 | 18 | constructor(device: GPUDevice) { 19 | const shaderModule = device.createShaderModule({ 20 | label: labelShader(HairSimIntegrationPass), 21 | code: SHADER_CODE(), 22 | }); 23 | this.pipeline = device.createComputePipeline({ 24 | label: labelPipeline(HairSimIntegrationPass), 25 | layout: 'auto', 26 | compute: { 27 | module: shaderModule, 28 | entryPoint: 'main', 29 | }, 30 | }); 31 | } 32 | 33 | cmdSimulateHairPositions(ctx: PassCtx, hairObject: HairObject) { 34 | const { cmdBuf, profiler } = ctx; 35 | 36 | const computePass = cmdBuf.beginComputePass({ 37 | label: HairSimIntegrationPass.NAME, 38 | timestampWrites: profiler?.createScopeGpu(HairSimIntegrationPass.NAME), 39 | }); 40 | 41 | const bindings = this.bindingsCache.getBindings( 42 | `${hairObject.name}-${hairObject.currentPositionsBufferIdx}`, 43 | () => this.createBindings(ctx, hairObject) 44 | ); 45 | computePass.setPipeline(this.pipeline); 46 | computePass.setBindGroup(0, bindings); 47 | 48 | // dispatch 49 | const workgroupsX = getItemsPerThread( 50 | hairObject.strandsCount, 51 | SHADER_PARAMS.workgroupSizeX 52 | ); 53 | // console.log(`${HairSimIntegrationPass.NAME}.dispatch(${workgroupsX}, 1, 1)`); 54 | computePass.dispatchWorkgroups(workgroupsX, 1, 1); 55 | 56 | computePass.end(); 57 | 58 | // every pass after that will use updated values 59 | hairObject.swapPositionBuffersAfterSimIntegration(); 60 | } 61 | 62 | private createBindings = ( 63 | ctx: PassCtx, 64 | hairObject: HairObject 65 | ): GPUBindGroup => { 66 | const { device, simulationUniforms, scene, physicsForcesGrid } = ctx; 67 | const b = SHADER_PARAMS.bindings; 68 | const sdf = scene.sdfCollider; 69 | 70 | return assignResourcesToBindings2( 71 | HairSimIntegrationPass, 72 | `${hairObject.name}-${hairObject.currentPositionsBufferIdx}`, 73 | device, 74 | this.pipeline, 75 | [ 76 | simulationUniforms.createBindingDesc(b.simulationUniforms), 77 | hairObject.bindHairData(b.hairData), 78 | hairObject.bindInitialSegmentLengths(b.segmentLengths), 79 | hairObject.bindPointsPositions_PREV(b.positionsPrev), 80 | hairObject.bindPointsPositions(b.positionsNow), 81 | hairObject.bindPointsPositions_INITIAL(b.positionsInitial), 82 | sdf.bindTexture(b.sdfTexture), 83 | sdf.bindSampler(b.sdfSampler), 84 | physicsForcesGrid.bindDensityVelocityBuffer(b.densityVelocityBuffer), 85 | physicsForcesGrid.bindDensityGradAndWindBuffer(b.densityGradWindBuffer), 86 | ] 87 | ); 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/passes/drawGizmo/drawGizmoPass.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG, GizmoAxis, GizmoAxisIdx } from '../../constants.ts'; 2 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 3 | import { 4 | labelShader, 5 | labelPipeline, 6 | useColorAttachment, 7 | assignResourcesToBindings, 8 | } from '../_shared/shared.ts'; 9 | import { PassCtx } from '../passCtx.ts'; 10 | import { SHADER_CODE, SHADER_PARAMS } from './drawGizmoPass.wgsl.ts'; 11 | 12 | export class DrawGizmoPass { 13 | public static NAME: string = 'DrawGizmoPass'; 14 | 15 | private readonly pipeline: GPURenderPipeline; 16 | private readonly bindingsCache = new BindingsCache(); 17 | 18 | constructor(device: GPUDevice, outTextureFormat: GPUTextureFormat) { 19 | const shaderModule = device.createShaderModule({ 20 | label: labelShader(DrawGizmoPass), 21 | code: SHADER_CODE(), 22 | }); 23 | 24 | this.pipeline = device.createRenderPipeline({ 25 | label: labelPipeline(DrawGizmoPass), 26 | layout: 'auto', 27 | vertex: { 28 | module: shaderModule, 29 | entryPoint: 'main_vs', 30 | }, 31 | fragment: { 32 | module: shaderModule, 33 | entryPoint: 'main_fs', 34 | targets: [{ format: outTextureFormat }], 35 | }, 36 | primitive: { 37 | cullMode: 'none', 38 | topology: 'triangle-list', 39 | }, 40 | }); 41 | } 42 | 43 | onViewportResize = () => { 44 | this.bindingsCache.clear(); 45 | }; 46 | 47 | cmdDrawGizmo(ctx: PassCtx) { 48 | const { cmdBuf, profiler, hdrRenderTexture } = ctx; 49 | 50 | const renderPass = cmdBuf.beginRenderPass({ 51 | label: DrawGizmoPass.NAME, 52 | colorAttachments: [ 53 | useColorAttachment(hdrRenderTexture, CONFIG.clearColor, 'load'), 54 | ], 55 | timestampWrites: profiler?.createScopeGpu(DrawGizmoPass.NAME), 56 | }); 57 | 58 | // set render pass data 59 | const bindings = this.bindingsCache.getBindings('-', () => 60 | this.createBindings(ctx) 61 | ); 62 | renderPass.setPipeline(this.pipeline); 63 | renderPass.setBindGroup(0, bindings); 64 | 65 | const cfg = CONFIG.colliderGizmo; 66 | if (cfg.isDragging) { 67 | // deno-lint-ignore no-explicit-any 68 | this.cmdDrawSingleAxis(renderPass, cfg.activeAxis as any); 69 | } else { 70 | this.cmdDrawAllAxis(renderPass); 71 | } 72 | 73 | // fin 74 | renderPass.end(); 75 | } 76 | 77 | private cmdDrawAllAxis(renderPass: GPURenderPassEncoder) { 78 | renderPass.draw( 79 | 6, // vertex count 80 | 3, // instance count 81 | 0, // first index 82 | 0 // first instance 83 | ); 84 | } 85 | 86 | private cmdDrawSingleAxis( 87 | renderPass: GPURenderPassEncoder, 88 | axisIdx: GizmoAxisIdx 89 | ) { 90 | if (axisIdx === GizmoAxis.NONE) { 91 | return; 92 | } 93 | renderPass.draw( 94 | 6, // vertex count 95 | 1, // instance count 96 | 0, // first index 97 | axisIdx // first instance 98 | ); 99 | } 100 | 101 | private createBindings = (ctx: PassCtx): GPUBindGroup => { 102 | const { device, globalUniforms } = ctx; 103 | const b = SHADER_PARAMS.bindings; 104 | 105 | return assignResourcesToBindings(DrawGizmoPass, device, this.pipeline, [ 106 | globalUniforms.createBindingDesc(b.renderUniforms), 107 | ]); 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /src/passes/swHair/hairTileSortPass.countTiles.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 2 | import { BUFFER_SEGMENT_COUNT_PER_TILE } from './shared/segmentCountPerTileBuffer.ts'; 3 | import * as SHADER_SNIPPETS from '../_shaderSnippets/shaderSnippets.wgls.ts'; 4 | import { SHADER_TILE_UTILS } from './shaderImpl/tileUtils.wgsl.ts'; 5 | import { u32_type } from '../../utils/webgpu.ts'; 6 | import { CONFIG } from '../../constants.ts'; 7 | 8 | export const SHADER_PARAMS = { 9 | workgroupSizeX: 256, 10 | bindings: { 11 | renderUniforms: 0, 12 | segmentCountPerTile: 1, 13 | sortBuckets: 2, 14 | }, 15 | }; 16 | 17 | /////////////////////////// 18 | /// SORT UTILS 19 | /////////////////////////// 20 | 21 | export const SORT_BUCKETS_BUFFER = ( 22 | bindingIdx: number, 23 | pass: 'count-tiles' | 'sort' 24 | ) => /* wgsl */ ` 25 | 26 | const SORT_BUCKETS = ${CONFIG.hairRender.sortBuckets}u; 27 | const BUCKET_SIZE = ${CONFIG.hairRender.sortBucketSize}u; 28 | 29 | fn calcTileSortBucket(segmentCount: u32) -> u32 { 30 | let key = segmentCount / BUCKET_SIZE; 31 | return clamp(key, 0u, SORT_BUCKETS - 1u); 32 | } 33 | 34 | struct SortBucket { 35 | // 1 pass: WRITE: inc for each tile that has segment count in this bucket 36 | // 2 pass: READ: to get offsets 37 | tileCount: ${u32_type(pass === 'count-tiles' ? 'read_write' : 'read')}, 38 | // 1 pass: - 39 | // 2 pass: WRITE: in-bucket offsets 40 | writeOffset: ${u32_type(pass === 'count-tiles' ? 'read' : 'read_write')}, 41 | } 42 | 43 | @group(0) @binding(${bindingIdx}) 44 | var _buckets: array; 45 | `; 46 | 47 | /////////////////////////// 48 | /// SHADER CODE 49 | /////////////////////////// 50 | const c = SHADER_PARAMS; 51 | const b = SHADER_PARAMS.bindings; 52 | 53 | export const SHADER_CODE = () => /* wgsl */ ` 54 | 55 | ${SHADER_SNIPPETS.GENERIC_UTILS} 56 | ${SHADER_TILE_UTILS} 57 | ${SORT_BUCKETS_BUFFER(b.sortBuckets, 'count-tiles')} 58 | 59 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 60 | ${BUFFER_SEGMENT_COUNT_PER_TILE(b.segmentCountPerTile, 'read')} 61 | 62 | 63 | @compute 64 | @workgroup_size(${c.workgroupSizeX}, 1, 1) 65 | fn main( 66 | @builtin(global_invocation_id) global_id: vec3, 67 | @builtin(local_invocation_index) local_invocation_index: u32, // threadId inside workgroup 68 | ) { 69 | let tileIdx = global_id.x; 70 | let viewportSize: vec2f = _uniforms.viewport.xy; 71 | 72 | /*if (local_invocation_index == 0u) { 73 | for (var i = 0u; i < SORT_BUCKETS; i++) { 74 | atomicStore(_subResults[i], 0u); 75 | } 76 | } 77 | workgroupBarrier();*/ 78 | 79 | let screenTileCount_2d = getTileCount(vec2u(viewportSize)); 80 | let screenTileCount = screenTileCount_2d.x * screenTileCount_2d.y; 81 | let isValidTile = tileIdx < screenTileCount; 82 | 83 | let segmentCount = _hairSegmentCountPerTile[tileIdx]; 84 | if (isValidTile && segmentCount > 0u) { 85 | let sortBucket = calcTileSortBucket(segmentCount); 86 | // atomicAdd(&_subResults[sortBucket], 1u); 87 | atomicAdd(&_buckets[sortBucket].tileCount, 1u); 88 | } 89 | /*workgroupBarrier(); 90 | 91 | if (local_invocation_index == 0u) { 92 | for (var i = 0u; i < SORT_BUCKETS; i++) { 93 | let bucketValue = atomicLoad(_subResults[i]); 94 | _segmentsInBucket[i] = bucketValue; 95 | } 96 | }*/ 97 | } 98 | 99 | `; 100 | -------------------------------------------------------------------------------- /src/passes/hairShadingPass/hairShadingPass.ts: -------------------------------------------------------------------------------- 1 | import { CONFIG } from '../../constants.ts'; 2 | import { HairObject } from '../../scene/hair/hairObject.ts'; 3 | import { 4 | assertIsGPUTextureView, 5 | getItemsPerThread, 6 | } from '../../utils/webgpu.ts'; 7 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 8 | import { 9 | labelShader, 10 | labelPipeline, 11 | assignResourcesToBindings2, 12 | } from '../_shared/shared.ts'; 13 | import { PassCtx } from '../passCtx.ts'; 14 | import { SHADER_CODE, SHADER_PARAMS } from './hairShadingPass.wgsl.ts'; 15 | 16 | export class HairShadingPass { 17 | public static NAME: string = 'HairShadingPass'; 18 | 19 | private readonly pipeline: GPUComputePipeline; 20 | private readonly bindingsCache = new BindingsCache(); 21 | 22 | constructor(device: GPUDevice) { 23 | const shaderModule = device.createShaderModule({ 24 | label: labelShader(HairShadingPass), 25 | code: SHADER_CODE(), 26 | }); 27 | this.pipeline = device.createComputePipeline({ 28 | label: labelPipeline(HairShadingPass), 29 | layout: 'auto', 30 | compute: { 31 | module: shaderModule, 32 | entryPoint: 'main', 33 | }, 34 | }); 35 | } 36 | 37 | onViewportResize = () => { 38 | this.bindingsCache.clear(); 39 | }; 40 | 41 | cmdComputeShadingPoints(ctx: PassCtx, hairObject: HairObject) { 42 | const { cmdBuf, profiler } = ctx; 43 | 44 | const computePass = cmdBuf.beginComputePass({ 45 | label: HairShadingPass.NAME, 46 | timestampWrites: profiler?.createScopeGpu(HairShadingPass.NAME), 47 | }); 48 | 49 | const bindings = this.bindingsCache.getBindings(hairObject.name, () => 50 | this.createBindings(ctx, hairObject) 51 | ); 52 | computePass.setPipeline(this.pipeline); 53 | computePass.setBindGroup(0, bindings); 54 | 55 | // dispatch 56 | const workgroupsX = getItemsPerThread( 57 | hairObject.strandsCount, 58 | SHADER_PARAMS.workgroupSizeX 59 | ); 60 | const workgroupsY = getItemsPerThread( 61 | CONFIG.hairRender.shadingPoints, 62 | SHADER_PARAMS.workgroupSizeY 63 | ); 64 | // console.log(`${HairShadingPass.NAME}.dispatch(${workgroupsX}, ${workgroupsY}, 1)`); // prettier-ignore 65 | computePass.dispatchWorkgroups(workgroupsX, workgroupsY, 1); 66 | 67 | computePass.end(); 68 | } 69 | 70 | private createBindings = (ctx: PassCtx, object: HairObject): GPUBindGroup => { 71 | const { 72 | device, 73 | globalUniforms, 74 | shadowDepthTexture, 75 | shadowMapSampler, 76 | depthTexture, 77 | aoTexture, 78 | } = ctx; 79 | const b = SHADER_PARAMS.bindings; 80 | assertIsGPUTextureView(depthTexture); 81 | 82 | return assignResourcesToBindings2( 83 | HairShadingPass, 84 | object.name, 85 | device, 86 | this.pipeline, 87 | [ 88 | globalUniforms.createBindingDesc(b.renderUniforms), 89 | object.bindHairData(b.hairData), 90 | object.bindPointsPositions(b.hairPositions), 91 | object.bindTangents(b.hairTangents), 92 | object.bindShading(b.hairShading), 93 | { binding: b.shadowMapTexture, resource: shadowDepthTexture }, 94 | { binding: b.shadowMapSampler, resource: shadowMapSampler }, 95 | { binding: b.aoTexture, resource: aoTexture }, 96 | { binding: b.depthTexture, resource: depthTexture }, 97 | ] 98 | ); 99 | }; 100 | } 101 | -------------------------------------------------------------------------------- /src/scene/objLoader.ts: -------------------------------------------------------------------------------- 1 | import * as objLoader from 'webgl-obj-loader'; 2 | import { 3 | createGPU_IndexBuffer, 4 | createGPU_VertexBuffer, 5 | } from '../utils/webgpu.ts'; 6 | import { calculateBounds } from '../utils/bounds.ts'; 7 | import { createArray, ensureTypedArray } from '../utils/arrays.ts'; 8 | import { GPUMesh } from './gpuMesh.ts'; 9 | 10 | const Mesh = objLoader.default?.Mesh || objLoader.Mesh; // deno vs chrome 11 | 12 | interface ObjMesh { 13 | indices: number[]; 14 | vertices: number[]; 15 | vertexNormals?: Array; 16 | textures?: Array; 17 | } 18 | const getVertexCount = (mesh: ObjMesh) => Math.ceil(mesh.vertices.length / 3); 19 | const getIndexCount = (mesh: ObjMesh) => Math.ceil(mesh.indices.length / 3); 20 | 21 | export function loadObjFile( 22 | device: GPUDevice, 23 | name: string, 24 | objText: string, 25 | scale = 1 26 | ): GPUMesh { 27 | const mesh = new Mesh(objText) as ObjMesh; 28 | cleanupRawOBJData(mesh, scale); 29 | // console.log(mesh); 30 | 31 | const vertexCount = getVertexCount(mesh); 32 | const triangleCount = getIndexCount(mesh); 33 | 34 | const positions = ensureTypedArray(Float32Array, mesh.vertices); 35 | const positionsBuffer = createGPU_VertexBuffer( 36 | device, 37 | `${name}-positions`, 38 | positions 39 | ); 40 | const normalsBuffer = createGPU_VertexBuffer( 41 | device, 42 | `${name}-normals`, 43 | mesh.vertexNormals! 44 | ); 45 | const uvBuffer = createGPU_VertexBuffer( 46 | device, 47 | `${name}-uvs`, 48 | mesh.textures! 49 | ); 50 | const indexBuffer = createGPU_IndexBuffer( 51 | device, 52 | `${name}-indices`, 53 | mesh.indices 54 | ); 55 | const bounds = calculateBounds(positions); 56 | console.log(`Loaded OBJ object '${name}', bounds`, bounds.sphere); 57 | 58 | return { 59 | name, 60 | vertexCount, 61 | triangleCount, 62 | positionsBuffer, 63 | normalsBuffer, 64 | uvBuffer, 65 | indexBuffer, 66 | bounds, 67 | isColliderPreview: false, 68 | }; 69 | } 70 | 71 | const hasNormals = (mesh: ObjMesh) => { 72 | if (!mesh.vertexNormals || !Array.isArray(mesh.vertexNormals)) return false; 73 | const firstEl = mesh.vertexNormals[0]; 74 | return typeof firstEl === 'number' && !isNaN(firstEl); 75 | }; 76 | 77 | type MeshWithTextures = Required>; 78 | 79 | const hasUVs = (mesh: ObjMesh): mesh is ObjMesh & MeshWithTextures => { 80 | if (!mesh.textures || !Array.isArray(mesh.textures)) return false; 81 | const firstEl = mesh.textures[0]; 82 | return typeof firstEl === 'number' && !isNaN(firstEl); 83 | }; 84 | 85 | function cleanupRawOBJData(mesh: ObjMesh, scale: number) { 86 | mesh.vertices = mesh.vertices.map((e: number) => e * scale); 87 | 88 | if (!hasNormals(mesh)) { 89 | throw new Error(`Expected normals in the OBJ file`); 90 | } 91 | 92 | if (!hasUVs(mesh)) { 93 | const vertCnt = getVertexCount(mesh); 94 | mesh.textures = createArray(vertCnt * 2).fill(0.5); 95 | } else { 96 | for (let i = 0; i < mesh.textures.length; i += 1) { 97 | let v = mesh.textures[i]; 98 | v = v % 1; // to range [0-1] 99 | v = v < 0 ? 1.0 - Math.abs(v) : v; // negative to positive 100 | // v = (i & 1) == 0 ? 1 - v : v; // invert X - not needed 101 | v = (i & 1) == 1 ? 1 - v : v; // invert Y - webgpu coordinate system 102 | mesh.textures[i] = v; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/passes/presentPass/presentPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { SNIPPET_ACES } from '../_shaderSnippets/aces.wgsl.ts'; 2 | import { SNIPPET_DITHER } from '../_shaderSnippets/dither.wgsl.ts'; 3 | import { FULLSCREEN_TRIANGLE_POSITION } from '../_shaderSnippets/fullscreenTriangle.wgsl.ts'; 4 | import { LINEAR_DEPTH } from '../_shaderSnippets/linearDepth.wgsl.ts'; 5 | import { GENERIC_UTILS } from '../_shaderSnippets/shaderSnippets.wgls.ts'; 6 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 7 | import * as SHADER_SNIPPETS from '../_shaderSnippets/shaderSnippets.wgls.ts'; 8 | import { TEXTURE_AO } from '../aoPass/shared/textureAo.wgsl.ts'; 9 | 10 | export const SHADER_PARAMS = { 11 | bindings: { 12 | renderUniforms: 0, 13 | resultHDR_Texture: 1, 14 | depthTexture: 2, 15 | normalsTexture: 3, 16 | aoTexture: 4, 17 | }, 18 | }; 19 | 20 | /////////////////////////// 21 | /// SHADER CODE 22 | /////////////////////////// 23 | const b = SHADER_PARAMS.bindings; 24 | 25 | export const SHADER_CODE = () => /* wgsl */ ` 26 | 27 | ${FULLSCREEN_TRIANGLE_POSITION} 28 | ${SNIPPET_DITHER} 29 | ${SNIPPET_ACES} 30 | ${LINEAR_DEPTH} 31 | ${GENERIC_UTILS} 32 | ${SHADER_SNIPPETS.NORMALS_UTILS} 33 | 34 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 35 | ${TEXTURE_AO(b.aoTexture)} 36 | 37 | @group(0) @binding(${b.resultHDR_Texture}) 38 | var _resultHDR_Texture: texture_2d; 39 | 40 | @group(0) @binding(${b.depthTexture}) 41 | var _depthTexture: texture_depth_2d; 42 | 43 | @group(0) @binding(${b.normalsTexture}) 44 | var _normalsTexture: texture_2d; 45 | 46 | 47 | @vertex 48 | fn main_vs( 49 | @builtin(vertex_index) VertexIndex : u32 50 | ) -> @builtin(position) vec4f { 51 | return getFullscreenTrianglePosition(VertexIndex); 52 | } 53 | 54 | fn doGamma (color: vec3f, gammaValue: f32) -> vec3f { 55 | return pow(color, vec3f(1.0 / gammaValue)); 56 | } 57 | 58 | @fragment 59 | fn main_fs( 60 | @builtin(position) positionPxF32: vec4 61 | ) -> @location(0) vec4 { 62 | let gamma = _uniforms.colorMgmt.x; 63 | let exposure = _uniforms.colorMgmt.y; 64 | let ditherStr = _uniforms.colorMgmt.z; 65 | 66 | let fragPositionPx = vec2u(positionPxF32.xy); 67 | var color = vec3f(0.0); 68 | let resultColor = textureLoad(_resultHDR_Texture, fragPositionPx, 0).rgb; 69 | let displayMode = getDisplayMode(); 70 | 71 | if ( 72 | displayMode == DISPLAY_MODE_FINAL || 73 | displayMode == DISPLAY_MODE_HW_RENDER 74 | ) { 75 | color = resultColor; 76 | color = ditherColor(fragPositionPx, color, ditherStr); 77 | color = color * exposure; 78 | color = saturate(doACES_Tonemapping(color)); 79 | color = doGamma(color, gamma); 80 | 81 | } else if (displayMode == DISPLAY_MODE_DEPTH) { 82 | let depth: f32 = textureLoad(_depthTexture, fragPositionPx, 0); 83 | var c = linearizeDepth_0_1(depth); 84 | // let rescale = vec2f(0.005, 0.009); 85 | let rescale = vec2f(0.002, 0.01); 86 | c = mapRange(rescale.x, rescale.y, 0., 1., c); 87 | color = vec3f(c); 88 | 89 | } else if (displayMode == DISPLAY_MODE_NORMALS) { 90 | let normalsOct: vec2f = textureLoad(_normalsTexture, fragPositionPx, 0).xy; 91 | let normal = decodeOctahedronNormal(normalsOct); 92 | color = vec3f(abs(normal.xyz)); 93 | 94 | } else if (displayMode == DISPLAY_MODE_AO) { 95 | let ao = sampleAo(vec2f(_uniforms.viewport.xy), positionPxF32.xy); 96 | color = vec3f(ao); 97 | 98 | } else { 99 | color = resultColor; 100 | } 101 | 102 | return vec4(color.xyz, 1.0); 103 | } 104 | 105 | `; 106 | -------------------------------------------------------------------------------- /src/utils/raycast.ts: -------------------------------------------------------------------------------- 1 | import { Vec3 } from 'webgl-obj-loader'; 2 | import { Mat4, Vec2, vec2, mat4, vec3, vec4 } from 'wgpu-matrix'; 3 | import { Dimensions } from './index.ts'; 4 | import { projectPointWithPerspDiv } from './matrices.ts'; 5 | 6 | // https://github.com/Scthe/Animation-workshop/blob/master/src/gl-utils/raycast/Ray.ts 7 | export interface Ray { 8 | origin: Vec3; 9 | dir: Vec3; 10 | } 11 | 12 | const TMP_VEC2 = vec2.create(); 13 | const TMP_VEC4_0 = vec4.create(); 14 | const TMP_VEC4_1 = vec4.create(); 15 | const TMP_MAT_4 = mat4.create(); 16 | 17 | /** 18 | * http://nelari.us/post/gizmos/ 19 | * 20 | * Given camera settings and pixel position, calculate ray 21 | */ 22 | export const generateRayFromCamera = ( 23 | viewport: Dimensions, 24 | viewProjMat: Mat4, 25 | mousePosPx: Vec2, 26 | result: Ray 27 | ): Ray => { 28 | const mousePosNDC = vec2.set( 29 | mousePosPx[0] / viewport.width, 30 | mousePosPx[1] / viewport.height, 31 | TMP_VEC2 32 | ); 33 | mousePosNDC[0] = mousePosNDC[0] * 2 - 1; // [-1 .. 1] 34 | mousePosNDC[1] = (1 - mousePosNDC[1]) * 2 - 1; // [-1 .. 1] 35 | 36 | const vpInverse = mat4.invert(viewProjMat, TMP_MAT_4); 37 | // NOTE: there is a small issue of zNear. It does not matter for 3D picking 38 | const p0 = vec4.set(mousePosNDC[0], mousePosNDC[1], 0, 1, TMP_VEC4_0); // zMin = 0 39 | const p1 = vec4.set(mousePosNDC[0], mousePosNDC[1], 1, 1, TMP_VEC4_1); // zMax = 1, does not matter, just get `dir` 40 | // technically, camera world pos. In practice, offseted by zNear, but does not matter for us 41 | const rayOrigin = projectPointWithPerspDiv(vpInverse, p0, TMP_VEC4_0); 42 | const rayEnd = projectPointWithPerspDiv(vpInverse, p1, TMP_VEC4_1); 43 | 44 | vec3.copy(rayOrigin, result.origin); 45 | vec3.normalize(vec3.subtract(rayEnd, rayOrigin, TMP_VEC4_1), result.dir); 46 | return result; 47 | }; 48 | 49 | /** Move `t` along ray from origin and return point */ 50 | export const getPointAlongRay = (ray: Ray, t: number, result?: Vec3) => { 51 | return vec3.addScaled(ray.origin, ray.dir, t, result); 52 | }; 53 | 54 | /** Find closest point to 'p' that also lies on the specified ray */ 55 | export const projectPointOntoRay = (ray: Ray, p: Vec3, result?: Vec3) => { 56 | // dot op that we are going to use ignores ray.origin and uses (0,0,0) 57 | // as ray start. Subtract here from p to move to same space 58 | const p2 = vec3.subtract(p, ray.origin, result); 59 | 60 | // this is from the geometric definition of dot product 61 | const dist = vec3.dot(p2, ray.dir); 62 | return getPointAlongRay(ray, dist, result); 63 | }; 64 | 65 | const TMP_VEC3_pointToRayDistance = vec3.create(); 66 | export const pointToRayDistance = (ray: Ray, p: Vec3): number => { 67 | const projected = projectPointOntoRay(ray, p, TMP_VEC3_pointToRayDistance); 68 | return vec3.distance(p, projected); 69 | }; 70 | 71 | // const TMP_VEC3_findClosestPointOnSegment = vec3.create(); 72 | const TMP_VEC3_left = vec3.create(); 73 | const TMP_VEC3_right = vec3.create(); 74 | export const findClosestPointOnSegment = ( 75 | ray: Ray, 76 | lineStartWS: Vec3, 77 | lineEndWS: Vec3, 78 | iters = 5 79 | ): Vec3 => { 80 | const left = vec3.copy(lineStartWS, TMP_VEC3_left); 81 | const right = vec3.copy(lineEndWS, TMP_VEC3_right); 82 | 83 | // binary search. There might be analytical solution, but too tired to search internet for it. 84 | // And it's binary search! How often you actually get to write it? 85 | for (let i = 0; i < iters; i++) { 86 | const distLeft = pointToRayDistance(ray, left); 87 | const distRight = pointToRayDistance(ray, right); 88 | if (distLeft < distRight) { 89 | vec3.midpoint(left, right, right); 90 | } else { 91 | vec3.midpoint(left, right, left); 92 | } 93 | } 94 | 95 | const distLeft = pointToRayDistance(ray, left); 96 | const distRight = pointToRayDistance(ray, right); 97 | return distLeft < distRight ? left : right; 98 | }; 99 | -------------------------------------------------------------------------------- /src/passes/hairCombine/hairCombinePass.ts: -------------------------------------------------------------------------------- 1 | import { assertIsGPUTextureView, bindBuffer } from '../../utils/webgpu.ts'; 2 | import { SHADER_PARAMS, SHADER_CODE } from './hairCombinePass.wgsl.ts'; 3 | import { PassCtx } from '../passCtx.ts'; 4 | import { cmdDrawFullscreenTriangle } from '../_shaderSnippets/fullscreenTriangle.wgsl.ts'; 5 | import { BindingsCache } from '../_shared/bindingsCache.ts'; 6 | import { 7 | labelShader, 8 | labelPipeline, 9 | useColorAttachment, 10 | assignResourcesToBindings, 11 | } from '../_shared/shared.ts'; 12 | import { CONFIG } from '../../constants.ts'; 13 | 14 | export class HairCombinePass { 15 | public static NAME: string = 'HairCombinePass'; 16 | 17 | private readonly pipeline: GPURenderPipeline; 18 | private readonly bindingsCache = new BindingsCache(); 19 | 20 | constructor(device: GPUDevice, outTextureFormat: GPUTextureFormat) { 21 | const shaderModule = device.createShaderModule({ 22 | label: labelShader(HairCombinePass), 23 | code: SHADER_CODE(), 24 | }); 25 | 26 | this.pipeline = device.createRenderPipeline({ 27 | label: labelPipeline(HairCombinePass), 28 | layout: 'auto', 29 | vertex: { 30 | module: shaderModule, 31 | entryPoint: 'main_vs', 32 | buffers: [], 33 | }, 34 | fragment: { 35 | module: shaderModule, 36 | entryPoint: 'main_fs', 37 | targets: [ 38 | { 39 | format: outTextureFormat, 40 | blend: { 41 | // color is based on the alpha from the shader's output. 42 | // So we can decide in code. But it also has a discard() there. 43 | color: { 44 | srcFactor: 'src-alpha', 45 | dstFactor: 'one-minus-src-alpha', 46 | operation: 'add', 47 | }, 48 | alpha: { 49 | srcFactor: 'one', 50 | dstFactor: 'one', 51 | operation: 'add', 52 | }, 53 | }, 54 | }, 55 | ], 56 | }, 57 | primitive: { topology: 'triangle-list' }, 58 | // depthStencil: PIPELINE_DEPTH_ON, // done in hair compute shaders 59 | }); 60 | } 61 | 62 | onViewportResize = () => this.bindingsCache.clear(); 63 | 64 | cmdCombineRasterResults(ctx: PassCtx) { 65 | const { cmdBuf, profiler, hdrRenderTexture, depthTexture } = ctx; 66 | assertIsGPUTextureView(hdrRenderTexture); 67 | 68 | const renderPass = cmdBuf.beginRenderPass({ 69 | label: HairCombinePass.NAME, 70 | colorAttachments: [ 71 | // do not clear! 72 | useColorAttachment(hdrRenderTexture, CONFIG.clearColor, 'load'), 73 | ], 74 | // depthStencilAttachment: useDepthStencilAttachment(depthTexture, 'load'), // done in hair compute shaders 75 | timestampWrites: profiler?.createScopeGpu(HairCombinePass.NAME), 76 | }); 77 | 78 | const bindings = this.bindingsCache.getBindings(depthTexture.label, () => 79 | this.createBindings(ctx) 80 | ); 81 | renderPass.setBindGroup(0, bindings); 82 | renderPass.setPipeline(this.pipeline); 83 | cmdDrawFullscreenTriangle(renderPass); 84 | renderPass.end(); 85 | } 86 | 87 | private createBindings = (ctx: PassCtx): GPUBindGroup => { 88 | const { 89 | device, 90 | globalUniforms, 91 | hairTilesBuffer, 92 | hairTileSegmentsBuffer, 93 | hairRasterizerResultsBuffer, 94 | hairSegmentCountPerTileBuffer, 95 | } = ctx; 96 | const b = SHADER_PARAMS.bindings; 97 | 98 | return assignResourcesToBindings(HairCombinePass, device, this.pipeline, [ 99 | globalUniforms.createBindingDesc(b.renderUniforms), 100 | bindBuffer(b.tilesBuffer, hairTilesBuffer), 101 | bindBuffer(b.tileSegmentsBuffer, hairTileSegmentsBuffer), 102 | bindBuffer(b.rasterizeResultBuffer, hairRasterizerResultsBuffer), 103 | bindBuffer(b.segmentCountPerTile, hairSegmentCountPerTileBuffer), 104 | ]); 105 | }; 106 | } 107 | -------------------------------------------------------------------------------- /src/passes/drawGridDbg/drawGridDbgPass.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { GridDebugValue } from '../../constants.ts'; 2 | import { assignValueFromConstArray } from '../_shaderSnippets/nagaFixes.ts'; 3 | import { GENERIC_UTILS } from '../_shaderSnippets/shaderSnippets.wgls.ts'; 4 | import { RenderUniformsBuffer } from '../renderUniformsBuffer.ts'; 5 | import { BUFFER_GRID_DENSITY_GRADIENT_AND_WIND } from '../simulation/grids/densityGradAndWindGrid.wgsl.ts'; 6 | import { BUFFER_GRID_DENSITY_VELOCITY } from '../simulation/grids/densityVelocityGrid.wgsl.ts'; 7 | import { GRID_UTILS } from '../simulation/grids/grids.wgsl.ts'; 8 | 9 | export const SHADER_PARAMS = { 10 | bindings: { 11 | renderUniforms: 0, 12 | densityVelocityBuffer: 1, 13 | densityGradWindBuffer: 2, 14 | }, 15 | }; 16 | 17 | /////////////////////////// 18 | /// SHADER CODE 19 | /////////////////////////// 20 | const b = SHADER_PARAMS.bindings; 21 | 22 | export const SHADER_CODE = () => /* wgsl */ ` 23 | 24 | ${GENERIC_UTILS} 25 | ${GRID_UTILS} 26 | 27 | ${RenderUniformsBuffer.SHADER_SNIPPET(b.renderUniforms)} 28 | 29 | ${BUFFER_GRID_DENSITY_VELOCITY(b.densityVelocityBuffer, 'read')} 30 | ${BUFFER_GRID_DENSITY_GRADIENT_AND_WIND(b.densityGradWindBuffer, 'read')} 31 | 32 | struct VertexOutput { 33 | @builtin(position) position: vec4, 34 | @location(0) positionOS: vec4f, 35 | @location(1) uv: vec2f, 36 | }; 37 | 38 | const POSITIONS = array( 39 | vec2(0, 0), 40 | vec2(0, 1), 41 | vec2(1, 0), 42 | vec2(0, 1), 43 | vec2(1, 1), 44 | vec2(1, 0) 45 | ); 46 | 47 | 48 | @vertex 49 | fn main_vs( 50 | @builtin(vertex_index) inVertexIndex : u32 // 0..6 51 | ) -> VertexOutput { 52 | let boundsMin = _uniforms.gridData.boundsMin.xyz; 53 | let boundsMax = _uniforms.gridData.boundsMax.xyz; 54 | let depthSlice = getGridDebugDepthSlice(); 55 | 56 | // TODO [LOW] same as SDF. Move to shared lib 57 | ${assignValueFromConstArray('uv: vec2f', 'POSITIONS', 6, 'inVertexIndex')} 58 | var positionOS = mix(boundsMin, boundsMax, vec3f(uv, depthSlice)); 59 | 60 | var result: VertexOutput; 61 | let mvpMatrix = _uniforms.mvpMatrix; 62 | 63 | result.position = mvpMatrix * vec4f(positionOS, 1.0); 64 | result.positionOS = vec4f(positionOS, 1.0); 65 | result.uv = uv; 66 | return result; 67 | } 68 | 69 | 70 | @fragment 71 | fn main_fs( 72 | fragIn: VertexOutput 73 | ) -> @location(0) vec4f { 74 | let boundsMin = _uniforms.gridData.boundsMin.xyz; 75 | let boundsMax = _uniforms.gridData.boundsMax.xyz; 76 | var opacity = 0.6; 77 | 78 | var displayMode = _uniforms.gridData.debugDisplayValue; 79 | let absTheVector: bool = displayMode >= 16u; 80 | displayMode = select(displayMode, displayMode - 16u, absTheVector); 81 | 82 | let positionOS = fragIn.positionOS.xyz; 83 | 84 | var color = vec3f(0., 0., 0.); 85 | let densityVelocity = _getGridDensityVelocity( 86 | boundsMin, 87 | boundsMax, 88 | positionOS 89 | ); 90 | let gridPoint = getClosestGridPoint( 91 | boundsMin, 92 | boundsMax, 93 | positionOS 94 | ); 95 | let densityGradAndWind = _getGridDensityGradAndWindAtPoint(gridPoint); 96 | 97 | 98 | if (displayMode == ${GridDebugValue.VELOCITY}u) { 99 | getVectorColor(&color, densityVelocity.velocity, absTheVector); 100 | 101 | } else if (displayMode == ${GridDebugValue.DENSITY_GRADIENT}u) { 102 | let grad = densityGradAndWind.densityGrad; 103 | getVectorColor(&color, grad, absTheVector); 104 | 105 | } else if (displayMode == ${GridDebugValue.WIND}u) { 106 | let windStr = densityGradAndWind.windStrength; 107 | if (windStr < 0.01) { color.r = 1.0; } 108 | else if (windStr < 0.99) { color.b = 1.0; } 109 | else { color.g = 1.0; } 110 | 111 | } else { 112 | // color.r = select(0.0, 1.0, value.density != 0); 113 | color.r = densityVelocity.density; 114 | } 115 | 116 | // slight transparency for a bit easier debug 117 | return vec4f(color.xyz, opacity); 118 | } 119 | 120 | fn getVectorColor(color: ptr, v: vec3f, absTheVector: bool) { 121 | if (length(v) < 0.001){ return; } 122 | 123 | var result = normalize(v); 124 | if (absTheVector) { 125 | result = abs(result); 126 | } 127 | 128 | (*color) = result; 129 | } 130 | 131 | `; 132 | -------------------------------------------------------------------------------- /src/utils/bounds.ts: -------------------------------------------------------------------------------- 1 | import { vec3, Vec3 } from 'wgpu-matrix'; 2 | import { CO_PER_VERTEX, VERTS_IN_TRIANGLE } from '../constants.ts'; 3 | 4 | export interface Bounds3d { 5 | sphere: BoundingSphere; 6 | box: BoundingBox; 7 | } 8 | 9 | export function calculateBounds( 10 | vertices: Float32Array, 11 | indices?: Uint32Array 12 | ): Bounds3d { 13 | const box = indices 14 | ? calcBoundingBoxIndex(vertices, indices) 15 | : calcBoundingBox(vertices); 16 | return { box, sphere: calcBoundingSphere(box) }; 17 | } 18 | 19 | export type BoundingBoxPoint = [number, number, number]; 20 | export type BoundingBox = [BoundingBoxPoint, BoundingBoxPoint]; 21 | 22 | type VertexCb = (v: [number, number, number]) => void; 23 | 24 | function yieldVertices( 25 | vertices: Float32Array, 26 | stride = CO_PER_VERTEX, 27 | cb: VertexCb 28 | ) { 29 | const vertCount = vertices.length / stride; 30 | const v: [number, number, number] = [0, 0, 0]; 31 | 32 | for (let i = 0; i < vertCount; i++) { 33 | const offset = i * stride; 34 | v[0] = vertices[offset]; 35 | v[1] = vertices[offset + 1]; 36 | v[2] = vertices[offset + 2]; 37 | cb(v); 38 | } 39 | } 40 | 41 | function yieldVerticesIndex( 42 | vertices: Float32Array, 43 | indices: Uint32Array, 44 | cb: VertexCb 45 | ) { 46 | const v: [number, number, number] = [0, 0, 0]; 47 | 48 | for (let i = 0; i < indices.length; i++) { 49 | const offset = indices[i] * VERTS_IN_TRIANGLE; 50 | v[0] = vertices[offset]; 51 | v[1] = vertices[offset + 1]; 52 | v[2] = vertices[offset + 2]; 53 | cb(v); 54 | } 55 | } 56 | 57 | export function boundsCalculator(): [BoundingBox, VertexCb] { 58 | const maxCo: BoundingBoxPoint = [undefined!, undefined!, undefined!]; 59 | const minCo: BoundingBoxPoint = [undefined!, undefined!, undefined!]; 60 | const cb: VertexCb = (v) => { 61 | for (let co = 0; co < 3; co++) { 62 | maxCo[co] = maxCo[co] !== undefined ? Math.max(maxCo[co], v[co]) : v[co]; 63 | minCo[co] = minCo[co] !== undefined ? Math.min(minCo[co], v[co]) : v[co]; 64 | } 65 | }; 66 | return [[minCo, maxCo], cb]; 67 | } 68 | 69 | export function calcBoundingBox( 70 | vertices: Float32Array, 71 | stride = CO_PER_VERTEX 72 | ): BoundingBox { 73 | const [results, addVert] = boundsCalculator(); 74 | yieldVertices(vertices, stride, addVert); 75 | return results; 76 | } 77 | 78 | export function calcBoundingBoxIndex( 79 | vertices: Float32Array, 80 | indices: Uint32Array 81 | ): BoundingBox { 82 | const [results, addVert] = boundsCalculator(); 83 | yieldVerticesIndex(vertices, indices, addVert); 84 | return results; 85 | } 86 | 87 | export type BoundingSphere = { center: Vec3; radius: number }; 88 | 89 | export function isSameBoundingSphere( 90 | a: BoundingSphere | undefined, 91 | b: BoundingSphere | undefined 92 | ) { 93 | return ( 94 | a?.center[0] === b?.center[0] && 95 | a?.center[1] === b?.center[1] && 96 | a?.center[2] === b?.center[2] && 97 | a?.radius === b?.radius 98 | ); 99 | } 100 | 101 | export function calcBoundingSphere([ 102 | minCo, 103 | maxCo, 104 | ]: BoundingBox): BoundingSphere { 105 | const center = vec3.midpoint(minCo, maxCo); 106 | const radius = vec3.distance(center, maxCo); 107 | return { center, radius }; 108 | } 109 | 110 | export function printBoundingBox( 111 | vertices: Float32Array, 112 | stride = CO_PER_VERTEX 113 | ) { 114 | const [minCo, maxCo] = calcBoundingBox(vertices, stride); 115 | const p = (a: number[]) => '[' + a.map((x) => x.toFixed(2)).join(',') + ']'; 116 | console.log(`Bounding box min:`, p(minCo)); 117 | console.log(`Bounding box max:`, p(maxCo)); 118 | } 119 | 120 | export function scaleBoundingBox(bb: BoundingBox, scale: number): BoundingBox { 121 | if (scale <= 0) { 122 | throw new Error(`Invalid scale=${scale}`); 123 | } 124 | const [boundsMin, boundsMax] = bb; 125 | const center = vec3.midpoint(boundsMin, boundsMax); 126 | const v = vec3.subtract(boundsMax, center); // center -> bounds max 127 | const scaledV = vec3.scale(v, scale); 128 | return [ 129 | vec3.subtract(center, scaledV), // 130 | vec3.add(center, scaledV), 131 | // deno-lint-ignore no-explicit-any 132 | ] as any; // Vec3 vs [number, number, number] 133 | } 134 | -------------------------------------------------------------------------------- /src/sys_web/input.ts: -------------------------------------------------------------------------------- 1 | /************** 2 | * Copied from official webgpu-samples repo under 3 | * 'BSD 3-Clause "New" or "Revised" License'. 4 | * 5 | * https://github.com/webgpu/webgpu-samples/blob/main/LICENSE.txt 6 | * https://webgpu.github.io/webgpu-samples/?sample=cameras 7 | */ 8 | 9 | const Key = { 10 | CAMERA_FORWARD: 'w', 11 | CAMERA_BACK: 's', 12 | CAMERA_LEFT: 'a', 13 | CAMERA_RIGHT: 'd', 14 | CAMERA_UP: ' ', 15 | CAMERA_DOWN: 'z', 16 | CAMERA_GO_FASTER: 'shift', 17 | }; 18 | 19 | // Input holds as snapshot of input state 20 | export default interface Input { 21 | // Digital input (e.g keyboard state) 22 | readonly directions: { 23 | forward: boolean; 24 | backward: boolean; 25 | left: boolean; 26 | right: boolean; 27 | up: boolean; 28 | down: boolean; 29 | goFaster: boolean; 30 | }; 31 | // Analog input (e.g mouse, touchscreen) 32 | readonly mouse: { 33 | x: number; 34 | y: number; 35 | dragX: number; 36 | dragY: number; 37 | touching: boolean; 38 | }; 39 | } 40 | 41 | export const createMockInputState = (): Input => ({ 42 | directions: { 43 | forward: false, 44 | backward: false, 45 | left: false, 46 | right: false, 47 | up: false, 48 | down: false, 49 | goFaster: false, 50 | }, 51 | mouse: { 52 | x: 0, 53 | y: 0, 54 | dragX: 0, 55 | dragY: 0, 56 | touching: false, 57 | }, 58 | }); 59 | 60 | // InputHandler is a function that when called, returns the current Input state. 61 | export type InputHandler = () => Input; 62 | 63 | // createInputHandler returns an InputHandler by attaching event handlers to the window and canvas. 64 | export function createInputHandler( 65 | window: Window, 66 | canvas: HTMLCanvasElement 67 | ): InputHandler { 68 | const { directions, mouse } = createMockInputState(); 69 | 70 | const setDigital = (e: KeyboardEvent, value: boolean) => { 71 | switch (e.key.toLowerCase()) { 72 | case Key.CAMERA_FORWARD: 73 | directions.forward = value; 74 | e.preventDefault(); 75 | e.stopPropagation(); 76 | break; 77 | case Key.CAMERA_BACK: 78 | directions.backward = value; 79 | e.preventDefault(); 80 | e.stopPropagation(); 81 | break; 82 | case Key.CAMERA_LEFT: 83 | directions.left = value; 84 | e.preventDefault(); 85 | e.stopPropagation(); 86 | break; 87 | case Key.CAMERA_RIGHT: 88 | directions.right = value; 89 | e.preventDefault(); 90 | e.stopPropagation(); 91 | break; 92 | case Key.CAMERA_UP: 93 | directions.up = value; 94 | e.preventDefault(); 95 | e.stopPropagation(); 96 | break; 97 | case Key.CAMERA_DOWN: 98 | directions.down = value; 99 | e.preventDefault(); 100 | e.stopPropagation(); 101 | break; 102 | case Key.CAMERA_GO_FASTER: 103 | directions.goFaster = value; 104 | e.preventDefault(); 105 | e.stopPropagation(); 106 | break; 107 | } 108 | }; 109 | 110 | window.addEventListener('keydown', (e) => setDigital(e, true)); 111 | window.addEventListener('keyup', (e) => setDigital(e, false)); 112 | 113 | canvas.style.touchAction = 'pinch-zoom'; 114 | canvas.addEventListener('pointerdown', () => { 115 | mouse.touching = true; 116 | }); 117 | canvas.addEventListener('pointerup', () => { 118 | mouse.touching = false; 119 | }); 120 | canvas.addEventListener('pointermove', (e: PointerEvent) => { 121 | // mouse.touching = e.pointerType == 'mouse' ? (e.buttons & 1) !== 0 : true; 122 | mouse.x = e.clientX; 123 | mouse.y = e.clientY; 124 | if (mouse.touching) { 125 | mouse.dragX += e.movementX; 126 | mouse.dragY += e.movementY; 127 | } 128 | }); 129 | 130 | return () => { 131 | const out: Input = { 132 | directions: { ...directions }, 133 | mouse: { ...mouse }, 134 | }; 135 | // Clear the analog values, as these accumulate between requestAnimationFrame(). 136 | // If you call check input state every 16ms, we want to accumulate the changes 137 | // over that time period. 138 | mouse.dragX = 0; 139 | mouse.dragY = 0; 140 | return out; 141 | }; 142 | } 143 | -------------------------------------------------------------------------------- /src/camera.ts: -------------------------------------------------------------------------------- 1 | import { Mat4, mat4, vec3 } from 'wgpu-matrix'; 2 | import Input from './sys_web/input.ts'; 3 | import { CONFIG, CameraPosition } from './constants.ts'; 4 | import { clamp } from './utils/index.ts'; 5 | import { STATS } from './stats.ts'; 6 | import { projectPoint } from './utils/matrices.ts'; 7 | 8 | const ANGLE_90_DRG_IN_RAD = Math.PI / 2; 9 | const ANGLE_UP_DOWN = 0; // pitch 10 | const ANGLE_LEFT_RIGHT = 1; // yaw 11 | 12 | /** https://github.com/Scthe/WebFX/blob/09713a3e7ebaa1484ff53bd8a007908a5340ca8e/src/ecs/components/FpsController.ts */ 13 | export class Camera { 14 | private readonly _viewMatrix = mat4.identity(); 15 | private readonly _tmpMatrix = mat4.identity(); // cache to prevent alloc 16 | /** Polar coordinate angles */ 17 | private readonly _angles: [number, number] = [0, 0]; 18 | /** Position world space */ 19 | private readonly _position: [number, number, number] = [0, 0, 0]; 20 | 21 | constructor(options: CameraPosition = CONFIG.camera.position) { 22 | this.resetPosition(options); 23 | } 24 | 25 | get positionWorldSpace() { 26 | return this._position; 27 | } 28 | 29 | resetPosition = (options: CameraPosition = CONFIG.camera.position) => { 30 | if (options.position?.length === 3) { 31 | this._position[0] = options.position[0]; 32 | this._position[1] = options.position[1]; 33 | this._position[2] = options.position[2]; 34 | } 35 | if (options.rotation?.length === 2) { 36 | this._angles[ANGLE_UP_DOWN] = options.rotation[1]; 37 | this._angles[ANGLE_LEFT_RIGHT] = options.rotation[0]; 38 | } 39 | }; 40 | 41 | update(deltaTime: number, input: Input): void { 42 | this.applyMovement(deltaTime, input); 43 | this.applyRotation(deltaTime, input); 44 | this.updateShownStats(); 45 | } 46 | 47 | private applyMovement(deltaTime: number, input: Input) { 48 | const sign = (positive: boolean, negative: boolean) => 49 | (positive ? 1 : 0) - (negative ? 1 : 0); 50 | 51 | const cfg = CONFIG.camera; 52 | const digital = input.directions; 53 | const m = 54 | deltaTime * 55 | (digital.goFaster ? cfg.movementSpeedFaster : cfg.movementSpeed); 56 | const moveDir: [number, number, number, number] = [0, 0, 0, 1]; 57 | moveDir[0] = m * sign(digital.right, digital.left); 58 | moveDir[1] = m * sign(digital.up, digital.down); 59 | moveDir[2] = m * sign(digital.backward, digital.forward); 60 | 61 | const rotMatrixInv = mat4.transpose(this.getRotationMat(), this._tmpMatrix); 62 | const moveDirLocal = projectPoint(rotMatrixInv, moveDir, moveDir); 63 | vec3.add(this._position, moveDirLocal, this._position); 64 | } 65 | 66 | private applyRotation(deltaTime: number, input: Input) { 67 | const cfg = CONFIG.camera; 68 | const yaw = input.mouse.dragX * deltaTime * cfg.rotationSpeed; 69 | const pitch = input.mouse.dragY * deltaTime * cfg.rotationSpeed; 70 | 71 | this._angles[ANGLE_LEFT_RIGHT] += yaw; 72 | this._angles[ANGLE_UP_DOWN] += pitch; 73 | const safeAngle = ANGLE_90_DRG_IN_RAD * 0.95; // no extremes pls! 74 | this._angles[ANGLE_UP_DOWN] = clamp( 75 | this._angles[ANGLE_UP_DOWN], 76 | -safeAngle, 77 | safeAngle 78 | ); 79 | } 80 | 81 | private updateShownStats() { 82 | const fmt = (x: number) => x.toFixed(1); 83 | const p = this._position; 84 | const r = this._angles; 85 | STATS.update('Camera pos', `[${fmt(p[0])}, ${fmt(p[1])}, ${fmt(p[2])}]`); 86 | STATS.update( 87 | 'Camera rot', 88 | `[${fmt(r[ANGLE_LEFT_RIGHT])}, ${fmt(r[ANGLE_UP_DOWN])}]` 89 | ); 90 | } 91 | 92 | private getRotationMat(): Mat4 { 93 | const angles = this._angles; 94 | const result = mat4.identity(this._tmpMatrix); 95 | mat4.rotateX(result, angles[ANGLE_UP_DOWN], result); // up-down 96 | mat4.rotateY(result, angles[ANGLE_LEFT_RIGHT], result); // left-right 97 | return result; 98 | } 99 | 100 | get viewMatrix(): Mat4 { 101 | const rotMat = this.getRotationMat(); 102 | const pos = this._position; 103 | 104 | // we have to reverse position, as moving camera X units 105 | // moves scene -X units 106 | return mat4.translate( 107 | rotMat, 108 | [-pos[0], -pos[1], -pos[2]], 109 | this._viewMatrix 110 | ); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/passes/simulation/shaderImpl/constraints.wgsl.ts: -------------------------------------------------------------------------------- 1 | export const HAIR_SIM_IMPL_CONSTRANTS = /* wgsl */ ` 2 | 3 | // See [Bender15] "Position-Based Simulation Methods in Computer Graphics" 4 | // Section "5.1. Stretching" 5 | fn applyConstraint_Length ( 6 | stiffness: f32, 7 | expectedLength: f32, 8 | pos0: ptr, 9 | pos1: ptr, 10 | ) { 11 | let w0 = isMovable(*pos0); 12 | let w1 = isMovable(*pos1); 13 | 14 | let tangent = (*pos1).xyz - (*pos0).xyz; // from pos0 toward pos1, unnormalized 15 | let actualLength = length(tangent); 16 | // if segment is SHORTER: (expectedLength / actualLength) > 1. 17 | // So we have to elongate it. $correction is negative, proportional to missing length. 18 | // if segment is LONGER: (expectedLength / actualLength) < 1. 19 | // So we have to shorten it. $correction is positive, proportional to extra length. 20 | let correction: f32 = 1.0 - expectedLength / actualLength; 21 | let deltaFactor: f32 = correction * stiffness / (w0 + w1 + FLOAT_EPSILON); 22 | let delta: vec3f = deltaFactor * tangent; 23 | 24 | // (*pos0) = getNextPosition((*pos0), (*pos0).xyz + delta); 25 | // (*pos1) = getNextPosition((*pos1), (*pos1).xyz - delta); 26 | (*pos0) += vec4f(w0 * delta, 0.0); 27 | (*pos1) -= vec4f(w1 * delta, 0.0); 28 | } 29 | 30 | 31 | fn globalConstraintAttenuation( 32 | globalExtent: f32, globalFade: f32, 33 | pointsPerStrand: u32, 34 | pointIdx: u32 35 | ) -> f32 { 36 | let x = f32(pointIdx) / f32(pointsPerStrand - 1u); 37 | return 1.0 - saturate((x - globalExtent) / globalFade); 38 | } 39 | 40 | fn applyConstraint_GlobalShape( 41 | wkGrpOffset: u32, 42 | stiffness: f32, 43 | posInitial: vec3f, 44 | pointIdx: u32 45 | ) { 46 | var pos = _positionsWkGrp[wkGrpOffset + pointIdx]; 47 | let deltaVec: vec3f = posInitial.xyz - pos.xyz; // pos -> posInitial 48 | _positionsWkGrp[wkGrpOffset + pointIdx] += vec4f(deltaVec * pos.w * stiffness, 0.0); 49 | } 50 | 51 | 52 | 53 | // A Triangle Bending Constraint Model for Position-Based Dynamics 54 | fn applyConstraint_LocalShape( 55 | wkGrpOffset: u32, 56 | pointsPerStrand: u32, strandIdx: u32, 57 | stiffness: f32, 58 | pointIdx: u32 59 | ) -> vec3f { 60 | // TODO [IGNORE] precompute 61 | let posInitial0 = _getHairPointPositionInitial(pointsPerStrand, strandIdx, pointIdx); 62 | let posInitial1 = _getHairPointPositionInitial(pointsPerStrand, strandIdx, pointIdx + 1u); 63 | let posInitial2 = _getHairPointPositionInitial(pointsPerStrand, strandIdx, pointIdx + 2u); 64 | let cInitial: vec3f = (posInitial0.xyz + posInitial1.xyz + posInitial2.xyz) / 3.; 65 | let h0 = length(cInitial - posInitial1.xyz); 66 | 67 | let wkOffset = wkGrpOffset + pointIdx; 68 | let pos0 = _positionsWkGrp[wkOffset]; 69 | let pos1 = _positionsWkGrp[wkOffset + 1u]; 70 | let pos2 = _positionsWkGrp[wkOffset + 2u]; 71 | let c: vec3f = (pos0.xyz + pos1.xyz + pos2.xyz) / 3.; 72 | let hVec = pos1.xyz - c; // from median toward middle point 73 | let h = length(hVec); 74 | 75 | let wTotal = posInitial0.w + 2. * posInitial1.w + posInitial2.w; 76 | let w0 = posInitial0.w / wTotal * 2.; 77 | let w1 = posInitial1.w / wTotal * -4.; 78 | let w2 = posInitial2.w / wTotal * 2.; 79 | 80 | let delta = hVec * (1.0 - h0 / h); 81 | _positionsWkGrp[wkOffset + 0u] += vec4f(stiffness * w0 * delta, 0.0); 82 | _positionsWkGrp[wkOffset + 1u] += vec4f(stiffness * w1 * delta, 0.0); 83 | _positionsWkGrp[wkOffset + 2u] += vec4f(stiffness * w2 * delta, 0.0); 84 | 85 | let lastTangent = _positionsWkGrp[wkOffset + 2u] - _positionsWkGrp[wkOffset + 1u]; 86 | return lastTangent.xyz; 87 | } 88 | 89 | 90 | fn applyConstraint_matchInitialTangent( 91 | wkGrpOffset: u32, 92 | pointsPerStrand: u32, strandIdx: u32, 93 | stiffness: f32, 94 | pointIdx: u32 95 | ) { 96 | // a bit wastefull, but ./shrug 97 | let posInitial0 = _getHairPointPositionInitial(pointsPerStrand, strandIdx, pointIdx - 1u); 98 | let posInitial1 = _getHairPointPositionInitial(pointsPerStrand, strandIdx, pointIdx); 99 | let tangentInitial = posInitial1.xyz - posInitial0.xyz; 100 | 101 | applyConstraint_matchTangent( 102 | stiffness, wkGrpOffset, pointIdx, tangentInitial 103 | ); 104 | } 105 | 106 | fn applyConstraint_matchTangent( 107 | stiffness: f32, 108 | wkGrpOffset: u32, 109 | pointIdx: u32, 110 | tangent: vec3f 111 | ) { 112 | let wkOffset = wkGrpOffset + pointIdx; 113 | let pos0 = _positionsWkGrp[wkOffset - 1u]; 114 | let pos1 = _positionsWkGrp[wkOffset]; 115 | 116 | let positionProj = pos0.xyz + tangent; 117 | let delta = positionProj - pos1.xyz; // now -> projected 118 | let w = pos1.w; 119 | _positionsWkGrp[wkOffset] += vec4f(stiffness * w * delta, 0.0); 120 | } 121 | 122 | `; 123 | -------------------------------------------------------------------------------- /src/passes/swHair/shared/hairTilesResultBuffer.ts: -------------------------------------------------------------------------------- 1 | import { BYTES_U32, CONFIG } from '../../../constants.ts'; 2 | import { STATS } from '../../../stats.ts'; 3 | import { Dimensions } from '../../../utils/index.ts'; 4 | import { formatBytes } from '../../../utils/string.ts'; 5 | import { StorageAccess, u32_type } from '../../../utils/webgpu.ts'; 6 | import { getTileCount } from './utils.ts'; 7 | 8 | /////////////////////////// 9 | /// SHADER CODE 10 | /////////////////////////// 11 | 12 | const storeTileDepth = /* wgsl */ ` 13 | 14 | fn _storeTileHead( 15 | viewportSize: vec2u, 16 | tileXY: vec2u, depthBin: u32, 17 | depthMin: f32, depthMax: f32, 18 | nextPtr: u32 19 | ) -> u32 { 20 | let tileIdx: u32 = getHairTileDepthBinIdx(viewportSize, tileXY, depthBin); 21 | 22 | // store depth 23 | // TODO [IGNORE] low precision. Convert this into 0-1 inside the bounding sphere and then quantisize 24 | let depthMax_U32 = u32(depthMax * f32(MAX_U32)); 25 | // WebGPU clears to 0. So atomicMin() is pointless. Use atomicMax() with inverted values instead 26 | let depthMin_U32 = u32((1.0 - depthMin) * f32(MAX_U32)); 27 | atomicMax(&_hairTilesResult[tileIdx].maxDepth, depthMax_U32); 28 | atomicMax(&_hairTilesResult[tileIdx].minDepth, depthMin_U32); 29 | 30 | // store pointer to 1st segment. 31 | // 0 is the value we cleared the buffer to. We always write +1, so previous value '0' 32 | // means this ptr was never modified. It signifies the end of the list. 33 | // But '0' is also a valid pointer into a linked list segments buffer. 34 | // That's why we add 1. To detect this case and turn it into $INVALID_TILE_SEGMENT_PTR. 35 | // This $INVALID_TILE_SEGMENT_PTR will be then written to the linked list segments buffer. 36 | let lastHeadPtr = atomicExchange( 37 | &_hairTilesResult[tileIdx].tileSegmentPtr, 38 | nextPtr + 1u 39 | ); 40 | 41 | return _translateHeadPointer(lastHeadPtr); 42 | } 43 | `; 44 | 45 | const getTileDepth = /* wgsl */ ` 46 | 47 | fn _getTileDepth(viewportSize: vec2u, tileXY: vec2u, depthBin: u32) -> vec2f { 48 | let tileIdx: u32 = getHairTileDepthBinIdx(viewportSize, tileXY, depthBin); 49 | let tile = _hairTilesResult[tileIdx]; 50 | return vec2f( 51 | f32(MAX_U32 - tile.minDepth) / f32(MAX_U32), 52 | f32(tile.maxDepth) / f32(MAX_U32) 53 | ); 54 | } 55 | 56 | fn _getTileSegmentPtr(viewportSize: vec2u, tileXY: vec2u, depthBin: u32) -> u32 { 57 | let tileIdx: u32 = getHairTileDepthBinIdx(viewportSize, tileXY, depthBin); 58 | let myPtr = _hairTilesResult[tileIdx].tileSegmentPtr; 59 | return _translateHeadPointer(myPtr); 60 | } 61 | 62 | `; 63 | 64 | /** 65 | * For each tile contains: 66 | * - min and max depth 67 | * - pointer into the tile segments buffer 68 | */ 69 | export const BUFFER_HAIR_TILES_RESULT = ( 70 | bindingIdx: number, 71 | access: StorageAccess 72 | ) => /* wgsl */ ` 73 | 74 | const MAX_U32: u32 = 0xffffffffu; 75 | const INVALID_TILE_SEGMENT_PTR: u32 = 0xffffffffu; 76 | 77 | struct HairTileResult { 78 | minDepth: ${u32_type(access)}, 79 | maxDepth: ${u32_type(access)}, 80 | tileSegmentPtr: ${u32_type(access)}, 81 | padding0: u32 82 | } 83 | 84 | @group(0) @binding(${bindingIdx}) 85 | var _hairTilesResult: array; 86 | 87 | ${access == 'read_write' ? storeTileDepth : getTileDepth} 88 | 89 | fn _translateHeadPointer(segmentPtr: u32) -> u32 { 90 | // PS. there is no ternary in WGSL. There is select(). It was designed by someone THAT HAS NEVER WRITTEN A LINE OF CODE IN THEIR LIFE. I.N.C.O.M.P.E.T.E.N.C.E. 91 | if (segmentPtr == 0u) { return INVALID_TILE_SEGMENT_PTR; } 92 | return segmentPtr - 1u; 93 | } 94 | `; 95 | 96 | /////////////////////////// 97 | /// GPU BUFFER 98 | /////////////////////////// 99 | 100 | export function createHairTilesResultBuffer( 101 | device: GPUDevice, 102 | viewportSize: Dimensions 103 | ): GPUBuffer { 104 | const TILE_DEPTH_BINS_COUNT = CONFIG.hairRender.tileDepthBins; 105 | const tileCount = getTileCount(viewportSize); 106 | console.log(`Creating hair tiles buffer: ${tileCount.width}x${tileCount.height}x${TILE_DEPTH_BINS_COUNT} tiles`); // prettier-ignore 107 | STATS.update( 108 | 'Tiles', 109 | `${tileCount.width} x ${tileCount.height} x ${TILE_DEPTH_BINS_COUNT}` 110 | ); 111 | 112 | const entries = tileCount.width * tileCount.height * TILE_DEPTH_BINS_COUNT; 113 | const bytesPerEntry = 4 * BYTES_U32; 114 | const size = entries * bytesPerEntry; 115 | STATS.update('Tiles heads', formatBytes(size)); 116 | 117 | const extraUsage = CONFIG.isTest ? GPUBufferUsage.COPY_SRC : 0; // for stats, debug etc. 118 | return device.createBuffer({ 119 | label: `hair-tiles-result`, 120 | size, 121 | usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | extraUsage, 122 | }); 123 | } 124 | -------------------------------------------------------------------------------- /src/passes/simulation/grids/densityVelocityGrid.wgsl.ts: -------------------------------------------------------------------------------- 1 | import { i32_type } from '../../../utils/webgpu.ts'; 2 | 3 | export const BUFFER_GRID_DENSITY_VELOCITY = ( 4 | bindingIdx: number, 5 | access: 'read_write' | 'read' 6 | ) => /* wgsl */ ` 7 | 8 | struct DensityVelocityI32 { 9 | velocityX: ${i32_type(access)}, 10 | velocityY: ${i32_type(access)}, 11 | velocityZ: ${i32_type(access)}, 12 | density: ${i32_type(access)}, 13 | } 14 | 15 | @group(0) @binding(${bindingIdx}) 16 | var _gridDensityVelocity: array; 17 | 18 | ${access === 'read' ? gridRead() : gridWrite()} 19 | 20 | `; 21 | 22 | function gridRead() { 23 | return /* wgsl */ ` 24 | 25 | struct DensityVelocity { 26 | velocity: vec3f, 27 | density: f32, 28 | } 29 | 30 | fn _getGridDensityVelocity( 31 | gridBoundsMin: vec3f, 32 | gridBoundsMax: vec3f, 33 | p: vec3f 34 | ) -> DensityVelocity { 35 | // get coords 36 | let co = _getGridCell(gridBoundsMin, gridBoundsMax, p); 37 | var result = DensityVelocity(vec3f(0.0), 0.0); 38 | 39 | // gather results from all 8 cell-points around 40 | // dbg: cellMax = cellMin; - only the min cell 41 | for (var z = co.cellMin.z; z <= co.cellMax.z; z += 1u) { 42 | for (var y = co.cellMin.y; y <= co.cellMax.y; y += 1u) { 43 | for (var x = co.cellMin.x; x <= co.cellMax.x; x += 1u) { 44 | let cellCorner = vec3u(x, y, z); 45 | let cornerWeights = _getGridCellWeights(cellCorner, co.pInGrid); 46 | let value = _getGridDensityVelocityAtPoint(cellCorner); 47 | 48 | // load 49 | result.velocity += cornerWeights * value.velocity; 50 | result.density += _getGridCellWeight(cornerWeights) * value.density; 51 | // result.density += cornerWeights.x * select(0., 1., x == 1u); // dbg: NOTE: this samples 4 points (see y,z-axis) so the gradient is a bit strong 52 | }}} 53 | 54 | // dbg: 55 | /*// let idx = _getGridIdx(cellMax); 56 | // let idx = _getGridIdx(cellMin); 57 | // let idx = 0; // remember, this is cell idx, not u32's offset! 58 | // let idx = clamp(cellMax.x, 0u, GRID_DIMS - 1u); 59 | // let value: vec4i = _gridDensityVelocity[idx]; 60 | // result.density = select(0.0, 1.0, value.w != 0); 61 | // result.density = select(0.0, 1.0, idx == 3u); 62 | // result.density = select(0.0, 1.0, result.density >= 4); 63 | // result.density = select(0.0, 1.0, cellMin.x == 2u); 64 | // result.density = select(0.0, 1.0, cellMax.x == 3u); 65 | var t: vec3f = saturate((p - gridBoundsMin) / (gridBoundsMax - gridBoundsMin)); 66 | result.density = select(0.0, 1.0, t.z > 0.5); // dbg: mock*/ 67 | return result; 68 | } 69 | 70 | 71 | fn _getGridDensityVelocityAtPoint(p: vec3u) -> DensityVelocity { 72 | var result = DensityVelocity(vec3f(0.0), 0.0); 73 | let idx = _getGridIdx(p); 74 | let value = _gridDensityVelocity[idx]; 75 | 76 | result.velocity.x = gridDecodeValue(value.velocityX); 77 | result.velocity.y = gridDecodeValue(value.velocityY); 78 | result.velocity.z = gridDecodeValue(value.velocityZ); 79 | result.density = gridDecodeValue(value.density); 80 | return result; 81 | } 82 | 83 | fn _getGridDensityAtPoint(p: vec3u) -> f32 { 84 | let idx = _getGridIdx(p); 85 | return gridDecodeValue(_gridDensityVelocity[idx].density); 86 | } 87 | `; 88 | } 89 | 90 | function gridWrite() { 91 | return /* wgsl */ ` 92 | 93 | fn _addGridDensityVelocity( 94 | gridBoundsMin: vec3f, 95 | gridBoundsMax: vec3f, 96 | p: vec3f, 97 | velocity: vec3f 98 | ) { 99 | // get coords 100 | let co = _getGridCell(gridBoundsMin, gridBoundsMax, p); 101 | 102 | // store into all 8 cell-points around 103 | for (var z = co.cellMin.z; z <= co.cellMax.z; z += 1u) { 104 | for (var y = co.cellMin.y; y <= co.cellMax.y; y += 1u) { 105 | for (var x = co.cellMin.x; x <= co.cellMax.x; x += 1u) { 106 | let cellCorner = vec3u(x, y, z); 107 | let idx = _getGridIdx(cellCorner); 108 | let cornerWeights = _getGridCellWeights(cellCorner, co.pInGrid); 109 | 110 | // store 111 | // velocity 112 | let cellVelocity: vec3f = velocity * cornerWeights; 113 | var value: i32 = gridEncodeValue(cellVelocity.x); 114 | atomicAdd(&_gridDensityVelocity[idx].velocityX, value); 115 | value = gridEncodeValue(cellVelocity.y); 116 | atomicAdd(&_gridDensityVelocity[idx].velocityY, value); 117 | value = gridEncodeValue(cellVelocity.z); 118 | atomicAdd(&_gridDensityVelocity[idx].velocityZ, value); 119 | // density 120 | let density = _getGridCellWeight(cornerWeights); 121 | value = gridEncodeValue(density); 122 | atomicAdd(&_gridDensityVelocity[idx].density, value); 123 | }}} 124 | } 125 | 126 | `; 127 | } 128 | --------------------------------------------------------------------------------