├── .prettierrc ├── .storybook ├── preview.js └── main.js ├── src ├── stories │ ├── glb.d.ts │ ├── cylinder-smooth.glb │ ├── tile-game-room6.glb │ ├── potpack.d.ts │ ├── Spinner.tsx │ ├── ao.stories.tsx │ ├── polyUV.stories.tsx │ ├── delay.stories.tsx │ ├── smooth.stories.tsx │ ├── simple.stories.tsx │ ├── multiMaterial.stories.tsx │ ├── DebugOverlayScene.tsx │ ├── text.stories.tsx │ └── gltf.stories.tsx ├── entry.ts └── core │ ├── WorkManager.tsx │ ├── bake.ts │ ├── offscreenWorkflow.tsx │ ├── Lightmap.tsx │ ├── workbench.ts │ ├── lightScene.ts │ ├── atlas.ts │ ├── AutoUV2.tsx │ └── lightProbe.ts ├── react-three-lightmap-example.png ├── .editorconfig ├── demo-sandbox ├── index.css ├── package.json └── index.jsx ├── .gitignore ├── LICENSE ├── tsconfig.json ├── README.md └── package.json /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: '^on[A-Z].*' } 3 | }; 4 | -------------------------------------------------------------------------------- /src/stories/glb.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.glb' { 2 | const url: string; 3 | export default url; 4 | } 5 | -------------------------------------------------------------------------------- /react-three-lightmap-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmndrs/react-three-lightmap/HEAD/react-three-lightmap-example.png -------------------------------------------------------------------------------- /src/stories/cylinder-smooth.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmndrs/react-three-lightmap/HEAD/src/stories/cylinder-smooth.glb -------------------------------------------------------------------------------- /src/stories/tile-game-room6.glb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pmndrs/react-three-lightmap/HEAD/src/stories/tile-game-room6.glb -------------------------------------------------------------------------------- /src/entry.ts: -------------------------------------------------------------------------------- 1 | import LightmapImpl from './core/Lightmap'; 2 | export * from './core/Lightmap'; 3 | export const Lightmap = LightmapImpl; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | indent_style = space 5 | indent_size = 2 6 | trim_trailing_whitespace = true 7 | -------------------------------------------------------------------------------- /demo-sandbox/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | box-sizing: border-box; 7 | height: 100%; 8 | margin: 0; 9 | } 10 | 11 | #root { 12 | height: 100%; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | dist/ 4 | build/ 5 | types/ 6 | Thumbs.db 7 | ehthumbs.db 8 | Desktop.ini 9 | $RECYCLE.BIN/ 10 | .DS_Store 11 | .vscode 12 | .docz/ 13 | package-lock.json 14 | coverage/ 15 | .idea 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | .size-snapshot.json 20 | __tests__/__image_snapshots__/__diff_output__ 21 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 3 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 4 | 5 | webpackFinal: async (config) => { 6 | config.module.rules.push({ 7 | test: /\.glb$/, 8 | use: ['file-loader'] 9 | }); 10 | 11 | return config; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/stories/potpack.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'potpack' { 2 | export interface PotPackItem { 3 | w: number; 4 | h: number; 5 | x: number; 6 | y: number; 7 | } 8 | 9 | export interface PotPackResult { 10 | w: number; 11 | h: number; 12 | fill: number; 13 | } 14 | 15 | function potpack(boxes: PotPackItem[]): PotPackResult; 16 | 17 | export default potpack; 18 | } 19 | -------------------------------------------------------------------------------- /demo-sandbox/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-sandbox", 3 | "version": "0.0.8", 4 | "description": "Sample @react-three/lightmap usage snippet", 5 | "main": "index.jsx", 6 | "license": "MIT", 7 | "dependencies": { 8 | "@react-three/drei": "^7.0.0", 9 | "@react-three/fiber": "^6.0.0", 10 | "@react-three/lightmap": "^0.0.8", 11 | "react": "^17.0.1", 12 | "react-dom": "^17.0.1", 13 | "three": "^0.128.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Poimandres 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. 22 | -------------------------------------------------------------------------------- /src/stories/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, useRef } from 'react'; 2 | import { useFrame } from '@react-three/fiber'; 3 | import * as THREE from 'three'; 4 | 5 | const Spinner: React.FC = () => { 6 | const meshRef = useRef(); 7 | 8 | useFrame(({ clock }) => { 9 | // @todo meshRef.current can be undefined on unmount, fix upstream 10 | if (meshRef.current && meshRef.current.rotation.isEuler) { 11 | meshRef.current.rotation.x = Math.sin(clock.elapsedTime * 0.2); 12 | meshRef.current.rotation.y = Math.sin(clock.elapsedTime * 0.5); 13 | meshRef.current.rotation.z = Math.sin(clock.elapsedTime); 14 | 15 | const initialZoom = Math.sin(Math.min(clock.elapsedTime, Math.PI / 2)); 16 | meshRef.current.scale.x = meshRef.current.scale.y = meshRef.current.scale.z = 17 | (1 + 0.2 * Math.sin(clock.elapsedTime * 1.5)) * initialZoom; 18 | } 19 | }); 20 | 21 | return ( 22 | <> 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default Spinner; 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "target": "ES2017", 6 | "module": "esnext", 7 | "lib": ["dom", "esnext"], 8 | "importHelpers": true, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | // output .js.map sourcemap files for consumers 12 | "sourceMap": true, 13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 14 | "rootDir": "./src", 15 | // stricter type-checking for stronger correctness. Recommended by TS 16 | "strict": true, 17 | // linter checks for common issues 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": true, 20 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | // use Node's module resolution algorithm, instead of the legacy TS one 24 | "moduleResolution": "node", 25 | // transpile JSX to React.createElement 26 | "jsx": "react", 27 | // interop between ESM and CJS modules. Recommended by TS 28 | "esModuleInterop": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/stories/ao.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | 7 | import Lightmap from '../core/Lightmap'; 8 | import Spinner from './Spinner'; 9 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 10 | 11 | export default { 12 | title: 'Simple AO', 13 | parameters: { 14 | layout: 'fullscreen' 15 | }, 16 | decorators: [(story) =>
{story()}
] 17 | } as Meta; 18 | 19 | export const Main: Story = () => ( 20 | { 24 | gl.toneMapping = THREE.ACESFilmicToneMapping; 25 | gl.toneMappingExposure = 0.9; 26 | 27 | gl.outputEncoding = THREE.sRGBEncoding; 28 | }} 29 | > 30 | 31 | }> 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 57 | 58 | ); 59 | -------------------------------------------------------------------------------- /src/stories/polyUV.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | 7 | import Lightmap, { LightmapReadOnly } from '../core/Lightmap'; 8 | import Spinner from './Spinner'; 9 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 10 | 11 | export default { 12 | title: 'Cylinder scene (polygon UV)', 13 | parameters: { 14 | layout: 'fullscreen' 15 | }, 16 | decorators: [(story) =>
{story()}
] 17 | } as Meta; 18 | 19 | export const Main: Story = () => ( 20 | { 24 | gl.toneMapping = THREE.ACESFilmicToneMapping; 25 | gl.toneMappingExposure = 0.9; 26 | 27 | gl.outputEncoding = THREE.sRGBEncoding; 28 | }} 29 | > 30 | 31 | }> 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 62 | 63 | ); 64 | -------------------------------------------------------------------------------- /src/stories/delay.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | 7 | import Lightmap from '../core/Lightmap'; 8 | import Spinner from './Spinner'; 9 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 10 | 11 | export default { 12 | title: 'Delay via disabled flag', 13 | parameters: { 14 | layout: 'fullscreen', 15 | docs: { 16 | source: { 17 | type: 'code' 18 | } 19 | } 20 | }, 21 | argTypes: { 22 | disabled: { 23 | control: { 24 | type: 'boolean', 25 | displayName: 'disabled' 26 | } 27 | } 28 | }, 29 | decorators: [(story) =>
{story()}
] 30 | } as Meta; 31 | 32 | type StoryWithArgs = Story<{ disabled: boolean }>; 33 | 34 | export const Main: StoryWithArgs = ({ disabled }) => ( 35 | { 39 | gl.toneMapping = THREE.ACESFilmicToneMapping; 40 | gl.toneMappingExposure = 0.9; 41 | 42 | gl.outputEncoding = THREE.sRGBEncoding; 43 | }} 44 | > 45 | 46 | }> 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 72 | 73 | ); 74 | Main.args = { 75 | disabled: true 76 | }; 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @react-three/lightmap 2 | 3 | **In-browser lightmap and ambient occlusion (AO map) baker for react-three-fiber and ThreeJS.** 4 | 5 | ![example screenshot of lightmap baker output](./react-three-lightmap-example.png) 6 | 7 | Example: 8 | 9 | ```jsx 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ``` 22 | 23 | **[Try it in this editable sandbox](https://codesandbox.io/s/github/pmndrs/react-three-lightmap/tree/v0.0.8/demo-sandbox)**. 24 | 25 | NOTE: actual lightmap rendering is performed on a separate hidden canvas and WebGL context. If you are consuming any context in your lightmapped content, you will need to "bridge" that context. 26 | 27 | To track when baking is complete, provide `onComplete` callback to `Lightmap` - it will be called with the resulting texture as the first argument. The library does automatically assign that texture as the lightmap on all the baked mesh materials too. 28 | 29 | ## Local Development 30 | 31 | ```sh 32 | git clone git@github.com:pmndrs/react-three-lightmap.git 33 | cd react-three-lightmap 34 | yarn 35 | yarn storybook 36 | ``` 37 | 38 | ## Wishlist 39 | 40 | - ~~onComplete callback~~ 41 | - proper denoising, calibrate the light sampler 42 | - much more optimization 43 | - composited multi-layer lightmap based on several distinct groups of light sources 44 | - e.g. for individual flickering lights, neon signs, etc 45 | - rudimentary light probe support for dynamic meshes/sprites 46 | - can start with just omnidirectional total amounts collected in 2D grid textures 47 | - might want the light probe pattern to be customizable 48 | - bake-only lights (turned off after bake) 49 | - useful for game levels - e.g. could have hundreds of lights baked in and then discarded 50 | - currently the lightmap is indirect-only, so this needs an extra step to sample direct light contribution 51 | - saving/loading the generated lightmap texture (useful for game levels) 52 | 53 | ## Notes 54 | 55 | Based on [original experimental implementation](https://github.com/unframework/threejs-lightmap-baker) by [@unframework](https://github.com/unframework). 56 | -------------------------------------------------------------------------------- /src/stories/smooth.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { useLoader, Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; 7 | 8 | import Lightmap, { LightmapReadOnly } from '../core/Lightmap'; 9 | import Spinner from './Spinner'; 10 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 11 | 12 | import sceneUrl from './cylinder-smooth.glb'; 13 | 14 | export default { 15 | title: 'Smooth normals scene', 16 | parameters: { 17 | layout: 'fullscreen' 18 | }, 19 | decorators: [(story) =>
{story()}
] 20 | } as Meta; 21 | 22 | const MainSceneContents: React.FC = () => { 23 | const { nodes } = useLoader(GLTFLoader, sceneUrl); 24 | 25 | // apply visual tweaks to our mesh 26 | const mesh = nodes.Cylinder; 27 | 28 | if ( 29 | mesh instanceof THREE.Mesh && 30 | mesh.material instanceof THREE.MeshStandardMaterial 31 | ) { 32 | mesh.material.metalness = 0; // override default full metalness (to have diffuse component) 33 | } 34 | 35 | mesh.castShadow = true; 36 | mesh.receiveShadow = true; 37 | 38 | return ( 39 | 40 | 41 | 42 | 47 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export const Main: Story = () => ( 55 | { 59 | gl.toneMapping = THREE.ACESFilmicToneMapping; 60 | gl.toneMappingExposure = 0.9; 61 | 62 | gl.outputEncoding = THREE.sRGBEncoding; 63 | }} 64 | > 65 | 66 | }> 67 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 84 | 85 | ); 86 | -------------------------------------------------------------------------------- /src/stories/simple.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | 7 | import Lightmap from '../core/Lightmap'; 8 | import Spinner from './Spinner'; 9 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 10 | 11 | export default { 12 | title: 'Simple scene', 13 | parameters: { 14 | layout: 'fullscreen' 15 | }, 16 | decorators: [(story) =>
{story()}
] 17 | } as Meta; 18 | 19 | export const Main: Story = () => ( 20 | { 24 | gl.toneMapping = THREE.ACESFilmicToneMapping; 25 | gl.toneMappingExposure = 0.9; 26 | 27 | gl.outputEncoding = THREE.sRGBEncoding; 28 | }} 29 | > 30 | 31 | }> 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 75 | 76 | ); 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-three/lightmap", 3 | "version": "0.0.8", 4 | "description": "In-browser lightmap/AO baker for react-three-fiber and ThreeJS", 5 | "keywords": [ 6 | "react", 7 | "threejs", 8 | "react-three-fiber", 9 | "r3f", 10 | "lightmap", 11 | "lightmap-baker", 12 | "ao", 13 | "ambient occlusion", 14 | "ao-baker" 15 | ], 16 | "main": "dist/index.js", 17 | "module": "dist/lightmap.esm.js", 18 | "typings": "dist/entry.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "engines": { 23 | "node": ">=10" 24 | }, 25 | "dependencies": { 26 | "potpack": "^1.0.1" 27 | }, 28 | "scripts": { 29 | "start": "tsdx watch --entry src/entry.ts", 30 | "build": "tsdx build --entry src/entry.ts", 31 | "test": "tsdx test --passWithNoTests", 32 | "lint": "tsdx lint", 33 | "prepare": "tsdx build --entry src/entry.ts", 34 | "size": "size-limit", 35 | "analyze": "size-limit --why", 36 | "storybook": "start-storybook -p 6006", 37 | "build-storybook": "build-storybook" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/pmndrs/react-three-lightmap.git" 42 | }, 43 | "author": "Nick Matantsev ", 44 | "license": "MIT", 45 | "bugs": { 46 | "url": "https://github.com/pmndrs/react-three-lightmap/issues" 47 | }, 48 | "devDependencies": { 49 | "@babel/core": "^7.12.17", 50 | "@react-three/drei": "^9.0.0", 51 | "@react-three/fiber": "^8.0.0", 52 | "@size-limit/preset-small-lib": "^4.9.2", 53 | "@storybook/addon-essentials": "^6.5.0", 54 | "@storybook/addon-info": "^5.3.21", 55 | "@storybook/addon-links": "^6.5.0", 56 | "@storybook/addons": "^6.5.0", 57 | "@storybook/react": "^6.5.0", 58 | "@types/react": "^18.0.0", 59 | "@types/react-dom": "^18.0.0", 60 | "@types/three": "^0.128.0", 61 | "babel-loader": "^8.2.2", 62 | "husky": "^5.0.9", 63 | "prettier": "^2.5.1", 64 | "react": "^18.0.0", 65 | "react-dom": "^18.0.0", 66 | "react-is": "^18.0.0", 67 | "size-limit": "^4.9.2", 68 | "three": "^0.128.0", 69 | "tsdx": "^0.14.1", 70 | "tslib": "^2.1.0", 71 | "typescript": "^4.1.5" 72 | }, 73 | "peerDependencies": { 74 | "@react-three/fiber": ">=8.0.0", 75 | "react": ">=16", 76 | "three": ">=0.128.0" 77 | }, 78 | "husky": { 79 | "hooks": { 80 | "pre-commit": "tsdx lint" 81 | } 82 | }, 83 | "size-limit": [ 84 | { 85 | "path": "dist/lightmap.cjs.production.min.js", 86 | "limit": "10 KB" 87 | }, 88 | { 89 | "path": "dist/lightmap.esm.js", 90 | "limit": "10 KB" 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /src/stories/multiMaterial.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | 7 | import Lightmap from '../core/Lightmap'; 8 | import Spinner from './Spinner'; 9 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 10 | 11 | export default { 12 | title: 'Multi-material mesh', 13 | parameters: { 14 | layout: 'fullscreen' 15 | }, 16 | decorators: [(story) =>
{story()}
] 17 | } as Meta; 18 | 19 | export const Main: Story = () => ( 20 | { 24 | gl.toneMapping = THREE.ACESFilmicToneMapping; 25 | gl.toneMappingExposure = 0.9; 26 | 27 | gl.outputEncoding = THREE.sRGBEncoding; 28 | }} 29 | > 30 | 31 | }> 32 | 33 | 34 | 35 | 36 | 37 | 38 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 74 | 75 | ); 76 | -------------------------------------------------------------------------------- /demo-sandbox/index.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Hi! This file can run inside CodeSandbox or a similar live-editing environment. 4 | * For local development, try the storybook files under src/stories. 5 | * 6 | */ 7 | 8 | import React, { useRef } from 'react'; 9 | import ReactDOM from 'react-dom'; 10 | import { Canvas, useLoader, useFrame } from '@react-three/fiber'; 11 | import { OrbitControls, Html } from '@react-three/drei'; 12 | import { Lightmap } from '@react-three/lightmap'; 13 | import * as THREE from 'three'; 14 | 15 | import './index.css'; 16 | 17 | /** 18 | * Try changing this! 19 | */ 20 | const DISPLAY_TEXT = 'Light!'; 21 | 22 | const Scene = () => { 23 | const font = useLoader( 24 | THREE.FontLoader, 25 | 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/fonts/helvetiker_regular.typeface.json' 26 | ); 27 | 28 | const lightTurntableRef = useRef(); 29 | useFrame(({ clock }) => { 30 | if (lightTurntableRef.current) { 31 | lightTurntableRef.current.rotation.y = -clock.elapsedTime * 0.1; 32 | } 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | ReactDOM.render( 67 | { 71 | gl.toneMapping = THREE.ACESFilmicToneMapping; 72 | gl.toneMappingExposure = 0.9; 73 | 74 | gl.outputEncoding = THREE.sRGBEncoding; 75 | }} 76 | > 77 | Loading font...}> 78 | }> 79 | 80 | 81 | 82 | 83 | 84 | 85 | 91 | , 92 | document.getElementById('root') 93 | ); 94 | -------------------------------------------------------------------------------- /src/stories/DebugOverlayScene.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useContext, useRef } from 'react'; 2 | import { useFrame, useThree, createPortal } from '@react-three/fiber'; 3 | import * as THREE from 'three'; 4 | 5 | import { DebugContext } from '../core/Lightmap'; 6 | 7 | const DebugOverlayContext = React.createContext(null); 8 | 9 | // set up a special render loop with a debug overlay for various widgets (see below) 10 | export const DebugOverlayRenderer: React.FC<{ children: React.ReactNode }> = ({ 11 | children 12 | }) => { 13 | const mainSceneRef = useRef(null); 14 | const debugSceneRef = useRef(null); 15 | 16 | const { size } = useThree(); 17 | const debugCamera = useMemo(() => { 18 | // top-left corner is (0, 100), top-right is (100, 100) 19 | const aspect = size.height / size.width; 20 | return new THREE.OrthographicCamera(0, 100, 100, 100 * (1 - aspect), -1, 1); 21 | }, [size]); 22 | 23 | useFrame(({ gl, camera }) => { 24 | gl.render(mainSceneRef.current!, camera); 25 | }, 20); 26 | 27 | useFrame(({ gl }) => { 28 | gl.autoClear = false; 29 | gl.clearDepth(); 30 | gl.render(debugSceneRef.current!, debugCamera); 31 | gl.autoClear = true; 32 | }, 30); 33 | 34 | return ( 35 | <> 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | {/* portal container for debug widgets */} 43 | 44 | 45 | ); 46 | }; 47 | 48 | // show provided textures as widgets on debug overlay (via createPortal) 49 | export const DebugOverlayWidgets: React.FC = React.memo(() => { 50 | const debugScene = useContext(DebugOverlayContext); 51 | const debugInfo = useContext(DebugContext); 52 | 53 | if (!debugScene || !debugInfo) { 54 | return null; 55 | } 56 | 57 | const { atlasTexture, outputTexture } = debugInfo; 58 | 59 | return ( 60 | <> 61 | {createPortal( 62 | <> 63 | {outputTexture && ( 64 | 65 | 66 | 71 | 72 | )} 73 | 74 | {atlasTexture && ( 75 | 76 | 77 | 82 | 83 | )} 84 | , 85 | debugScene 86 | )} 87 | 88 | ); 89 | }); 90 | -------------------------------------------------------------------------------- /src/stories/text.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { Canvas, useLoader } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | 7 | import Lightmap, { LightmapReadOnly } from '../core/Lightmap'; 8 | import Spinner from './Spinner'; 9 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 10 | 11 | export default { 12 | title: 'Text mesh scene', 13 | parameters: { 14 | layout: 'fullscreen' 15 | }, 16 | decorators: [(story) =>
{story()}
] 17 | } as Meta; 18 | 19 | const Scene: React.FC = () => { 20 | const font = useLoader( 21 | THREE.FontLoader, 22 | 'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/fonts/helvetiker_regular.typeface.json' 23 | ); 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 35 | 36 | 37 | 38 | 39 | 51 | 57 | 58 | 59 | 68 | 69 | 70 | 71 | 72 | ); 73 | }; 74 | 75 | export const Main: Story = () => ( 76 | { 80 | gl.toneMapping = THREE.ACESFilmicToneMapping; 81 | gl.toneMappingExposure = 0.9; 82 | 83 | gl.outputEncoding = THREE.sRGBEncoding; 84 | }} 85 | > 86 | 87 | }> 88 | 89 | 90 | 91 | 92 | 98 | 99 | ); 100 | -------------------------------------------------------------------------------- /src/core/WorkManager.tsx: -------------------------------------------------------------------------------- 1 | const DEFAULT_WORK_PER_FRAME = 2; 2 | 3 | export type WorkRequester = () => Promise; 4 | 5 | interface WorkTask { 6 | resolve: (gl: THREE.WebGLRenderer) => void; 7 | reject: (error: unknown) => void; 8 | promise: Promise | null; 9 | } 10 | 11 | const DUMMY_RESOLVER = (_gl: THREE.WebGLRenderer) => {}; 12 | const DUMMY_REJECTOR = (_error: unknown) => {}; 13 | 14 | // simple job queue to schedule per-frame work 15 | // (with some tricks like randomizing the task pop, etc) 16 | export function createWorkManager( 17 | gl: THREE.WebGLRenderer, 18 | abortPromise: Promise, 19 | workPerFrame?: number 20 | ): WorkRequester { 21 | const workPerFrameReal = Math.max(1, workPerFrame || DEFAULT_WORK_PER_FRAME); 22 | 23 | let rafActive = false; 24 | const pendingTasks: WorkTask[] = []; 25 | 26 | // wait for early stop 27 | let isStopped = false; 28 | abortPromise.then(() => { 29 | // prevent further scheduling 30 | isStopped = true; 31 | 32 | // clear out all the pending tasks 33 | const cleanupList = [...pendingTasks]; 34 | pendingTasks.length = 0; 35 | 36 | // safely notify existing awaiters that no more work can be done at all 37 | // (this helps clean up async jobs that were already scheduled) 38 | for (const task of cleanupList) { 39 | try { 40 | task.reject( 41 | new Error('work manager was unmounted while waiting for RAF') 42 | ); 43 | } catch (_error) { 44 | // no-op 45 | } 46 | } 47 | }); 48 | 49 | // awaitable request for next microtask inside RAF 50 | const requestWork = () => { 51 | // this helps break out of long-running async jobs 52 | if (isStopped) { 53 | throw new Error('work manager is no longer available'); 54 | } 55 | 56 | // schedule next RAF if needed 57 | if (!rafActive) { 58 | rafActive = true; 59 | 60 | async function rafRun() { 61 | for (let i = 0; i < workPerFrameReal; i += 1) { 62 | if (pendingTasks.length === 0) { 63 | // break out and stop the RAF loop for now 64 | rafActive = false; 65 | return; 66 | } 67 | 68 | // pick random microtask to run 69 | const taskIndex = Math.floor(Math.random() * pendingTasks.length); 70 | const task = pendingTasks[taskIndex]; 71 | pendingTasks.splice(taskIndex, 1); 72 | 73 | // notify pending worker 74 | task.resolve(gl); 75 | 76 | // give worker enough time to finish and possibly queue more work 77 | // to be run as part of this macrotask's frame 78 | await task.promise; 79 | await task.promise; // @todo this second await seems to make a difference to let worker finish on time! 80 | } 81 | 82 | // schedule more work right away in case more tasks are around 83 | requestAnimationFrame(rafRun); 84 | } 85 | 86 | requestAnimationFrame(rafRun); 87 | } 88 | 89 | // schedule the microtask 90 | let taskResolve = DUMMY_RESOLVER; 91 | let taskReject = DUMMY_REJECTOR; 92 | 93 | const promise = new Promise((resolve, reject) => { 94 | taskResolve = resolve; 95 | taskReject = reject; 96 | }); 97 | 98 | pendingTasks.push({ 99 | resolve: taskResolve, 100 | reject: taskReject, 101 | promise: promise 102 | }); 103 | 104 | return promise; 105 | }; 106 | 107 | return requestWork; 108 | } 109 | -------------------------------------------------------------------------------- /src/stories/gltf.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Story, Meta } from '@storybook/react'; 3 | import { useLoader, Canvas } from '@react-three/fiber'; 4 | import { OrbitControls } from '@react-three/drei'; 5 | import * as THREE from 'three'; 6 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'; 7 | 8 | import Lightmap, { LightmapReadOnly } from '../core/Lightmap'; 9 | import Spinner from './Spinner'; 10 | import { DebugOverlayRenderer, DebugOverlayWidgets } from './DebugOverlayScene'; 11 | 12 | import sceneUrl from './tile-game-room6.glb'; 13 | 14 | export default { 15 | title: 'glTF scene', 16 | parameters: { 17 | layout: 'fullscreen' 18 | }, 19 | decorators: [(story) =>
{story()}
] 20 | } as Meta; 21 | 22 | const MainSceneContents: React.FC = () => { 23 | // data loading 24 | const { nodes } = useLoader(GLTFLoader, sceneUrl); 25 | 26 | const lightStub = nodes.Light; 27 | 28 | const light = new THREE.DirectionalLight(); 29 | light.castShadow = true; 30 | 31 | // glTF import is still not great with lights, so we improvise 32 | light.intensity = lightStub.scale.z; 33 | light.shadow.camera.left = -lightStub.scale.x; 34 | light.shadow.camera.right = lightStub.scale.x; 35 | light.shadow.camera.top = lightStub.scale.y; 36 | light.shadow.camera.bottom = -lightStub.scale.y; 37 | 38 | light.position.copy(lightStub.position); 39 | 40 | const target = new THREE.Object3D(); 41 | target.position.set(0, 0, -1); 42 | target.position.applyEuler(lightStub.rotation); 43 | target.position.add(lightStub.position); 44 | 45 | light.target = target; 46 | 47 | const baseMesh = nodes.Base; 48 | if ( 49 | baseMesh instanceof THREE.Mesh && 50 | baseMesh.material instanceof THREE.MeshStandardMaterial 51 | ) { 52 | baseMesh.material.metalness = 0; // override default full metalness (to have diffuse component) 53 | baseMesh.material.side = THREE.FrontSide; 54 | 55 | if (baseMesh.material.map) { 56 | baseMesh.material.map.magFilter = THREE.NearestFilter; 57 | } 58 | 59 | baseMesh.castShadow = true; 60 | baseMesh.receiveShadow = true; 61 | } 62 | 63 | const coverMesh = nodes.Cover; 64 | if ( 65 | coverMesh instanceof THREE.Mesh && 66 | coverMesh.material instanceof THREE.MeshStandardMaterial 67 | ) { 68 | coverMesh.castShadow = true; // only cast shadow 69 | 70 | coverMesh.material.depthWrite = false; 71 | coverMesh.material.colorWrite = false; 72 | } 73 | 74 | return ( 75 | <> 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 97 | 98 | ); 99 | }; 100 | 101 | export const Main: Story = () => ( 102 | { 106 | gl.toneMapping = THREE.ACESFilmicToneMapping; 107 | gl.toneMappingExposure = 0.9; 108 | 109 | gl.outputEncoding = THREE.sRGBEncoding; 110 | }} 111 | > 112 | 113 | }> 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 128 | 129 | ); 130 | -------------------------------------------------------------------------------- /src/core/bake.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { scanAtlasTexels } from './atlas'; 4 | import { Workbench } from './workbench'; 5 | import { withLightProbe } from './lightProbe'; 6 | 7 | const MAX_PASSES = 2; 8 | 9 | // offsets for 3x3 brush 10 | const offDirX = [1, 1, 0, -1, -1, -1, 0, 1]; 11 | const offDirY = [0, 1, 1, 1, 0, -1, -1, -1]; 12 | 13 | function storeLightMapValue( 14 | atlasData: Float32Array, 15 | atlasWidth: number, 16 | totalTexelCount: number, 17 | texelIndex: number, 18 | rgba: THREE.Vector4, 19 | passOutputData: Float32Array 20 | ) { 21 | // read existing texel value (if adding) 22 | const mainOffTexelBase = texelIndex * 4; 23 | 24 | rgba.w = 1; // reset alpha to 1 to indicate filled pixel 25 | 26 | // main texel write 27 | rgba.toArray(passOutputData, mainOffTexelBase); 28 | 29 | // propagate combined value to 3x3 brush area 30 | const texelX = texelIndex % atlasWidth; 31 | const texelRowStart = texelIndex - texelX; 32 | 33 | for (let offDir = 0; offDir < 8; offDir += 1) { 34 | const offX = offDirX[offDir]; 35 | const offY = offDirY[offDir]; 36 | 37 | const offRowX = (atlasWidth + texelX + offX) % atlasWidth; 38 | const offRowStart = 39 | (totalTexelCount + texelRowStart + offY * atlasWidth) % totalTexelCount; 40 | const offTexelBase = (offRowStart + offRowX) * 4; 41 | 42 | // fill texel if it will not/did not receive real computed data otherwise; 43 | // also ensure strong neighbour values (not diagonal) take precedence 44 | // (using layer output data to check for past writes since it is re-initialized per pass) 45 | const offTexelFaceEnc = atlasData[offTexelBase + 2]; 46 | const isStrongNeighbour = offX === 0 || offY === 0; 47 | const isUnfilled = passOutputData[offTexelBase + 3] === 0; 48 | 49 | if (offTexelFaceEnc === 0 && (isStrongNeighbour || isUnfilled)) { 50 | // no need to separately read existing value for brush-propagated texels 51 | rgba.toArray(passOutputData, offTexelBase); 52 | } 53 | } 54 | } 55 | 56 | export async function runBakingPasses( 57 | workbench: Workbench, 58 | requestWork: () => Promise, 59 | onDebugPassComplete?: ( 60 | outputData: Float32Array, 61 | width: number, 62 | height: number 63 | ) => void 64 | ) { 65 | await withLightProbe( 66 | workbench.aoMode, 67 | workbench.aoDistance, 68 | workbench.settings, 69 | async (renderLightProbeBatch) => { 70 | const { atlasMap, irradiance, irradianceData } = workbench; 71 | const { width: atlasWidth, height: atlasHeight } = atlasMap; 72 | const totalTexelCount = atlasWidth * atlasHeight; 73 | 74 | // set up output buffer for texel data 75 | const passOutputData = new Float32Array(4 * totalTexelCount); 76 | 77 | for (let passCount = 0; passCount < MAX_PASSES; passCount += 1) { 78 | // reset output buffer "empty pixel" status (alpha channel) 79 | passOutputData.fill(0); 80 | 81 | // main work iteration 82 | let texelsDone = false; 83 | const texelIterator = scanAtlasTexels(atlasMap, () => { 84 | texelsDone = true; 85 | }); 86 | 87 | while (!texelsDone) { 88 | const gl = await requestWork(); 89 | 90 | for (const { texelIndex, rgba } of renderLightProbeBatch( 91 | gl, 92 | workbench.lightScene, 93 | texelIterator 94 | )) { 95 | // store resulting total illumination 96 | storeLightMapValue( 97 | atlasMap.data, 98 | atlasWidth, 99 | totalTexelCount, 100 | texelIndex, 101 | rgba, 102 | passOutputData 103 | ); 104 | } 105 | } 106 | 107 | // pass is complete, apply the computed texels into active lightmap 108 | // (used in the next pass) 109 | irradianceData.set(passOutputData); 110 | irradiance.needsUpdate = true; 111 | 112 | if (onDebugPassComplete) { 113 | onDebugPassComplete(passOutputData, atlasWidth, atlasHeight); 114 | } 115 | } 116 | } 117 | ); 118 | } 119 | 120 | // debug probe @todo rewrite 121 | /* 122 | const { renderLightProbeBatch: debugProbeBatch, debugLightProbeTexture } = 123 | useLightProbe( 124 | workbenchRef.current.aoMode, 125 | workbenchRef.current.aoDistance, 126 | workbenchRef.current.settings 127 | ); 128 | const debugProbeRef = useRef(false); 129 | useFrame(({ gl }) => { 130 | // run only once 131 | if (debugProbeRef.current) { 132 | return; 133 | } 134 | debugProbeRef.current = true; 135 | 136 | const { atlasMap } = workbenchRef.current; 137 | 138 | const startX = 1; 139 | const startY = 1; 140 | function* debugIterator() { 141 | yield getTexelInfo(atlasMap, atlasMap.width * startY + startX); 142 | } 143 | 144 | for (const _item of debugProbeBatch( 145 | gl, 146 | workbenchRef.current.lightScene, 147 | debugIterator() 148 | )) { 149 | // no-op (not consuming the data) 150 | } 151 | }); 152 | 153 | // report debug texture 154 | useEffect(() => { 155 | if (onDebugLightProbeRef.current) { 156 | onDebugLightProbeRef.current(debugLightProbeTexture); 157 | } 158 | }, [debugLightProbeTexture]); 159 | */ 160 | -------------------------------------------------------------------------------- /src/core/offscreenWorkflow.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-now Nick Matantsev 3 | * Licensed under the MIT license 4 | */ 5 | 6 | import React, { useState, useLayoutEffect, useRef } from 'react'; 7 | import { useThree, createRoot } from '@react-three/fiber'; 8 | import * as THREE from 'three'; 9 | 10 | import { withLightScene } from './lightScene'; 11 | import { initializeWorkbench, Workbench, WorkbenchSettings } from './workbench'; 12 | import { runBakingPasses } from './bake'; 13 | import { createWorkManager } from './WorkManager'; 14 | 15 | const WorkSceneWrapper: React.FC<{ 16 | onReady: (gl: THREE.WebGLRenderer, scene: THREE.Scene) => void; 17 | children: React.ReactNode; 18 | }> = (props) => { 19 | const { gl } = useThree(); // @todo use state selector 20 | 21 | // track latest reference to onReady callback 22 | const onReadyRef = useRef(props.onReady); 23 | onReadyRef.current = props.onReady; 24 | 25 | const sceneRef = useRef(null); 26 | useLayoutEffect(() => { 27 | // kick off the asynchronous workflow process in the parent 28 | // (this runs when scene content is loaded and suspensions are finished) 29 | const scene = sceneRef.current; 30 | if (!scene) { 31 | throw new Error('expecting lightmap scene'); 32 | } 33 | 34 | onReadyRef.current(gl, scene); 35 | }, [gl]); 36 | 37 | // main baking scene container 38 | return ( 39 | 40 | {props.children} 41 | 42 | ); 43 | }; 44 | 45 | export type OffscreenSettings = WorkbenchSettings & { 46 | workPerFrame?: number; // @todo allow fractions, dynamic value 47 | }; 48 | 49 | export interface Debug { 50 | onAtlasMap: (atlasMap: Workbench['atlasMap']) => void; 51 | onPassComplete: (data: Float32Array, width: number, height: number) => void; 52 | } 53 | 54 | // main async workflow, allows early cancellation via abortPromise 55 | async function runOffscreenWorkflow( 56 | content: React.ReactNode, 57 | settings: OffscreenSettings, 58 | abortPromise: Promise, 59 | debugListeners?: Debug 60 | ) { 61 | // render hidden canvas with the given content, wait for suspense to finish loading inside it 62 | const scenePromise = await new Promise<{ 63 | gl: THREE.WebGLRenderer; 64 | scene: THREE.Scene; 65 | }>((resolve) => { 66 | // just sensible small canvas, not actually used for direct output 67 | const canvas = document.createElement('canvas'); 68 | canvas.width = 64; 69 | canvas.height = 64; 70 | 71 | const root = createRoot(canvas).configure({ 72 | frameloop: 'never', // framebuffer target rendering is inside own RAF loop 73 | shadows: true // @todo use the original GL context settings 74 | }); 75 | 76 | root.render( 77 | 78 | { 80 | resolve({ gl, scene }); 81 | }} 82 | > 83 | {content} 84 | 85 | 86 | ); 87 | }); 88 | 89 | // preempt any further logic if already aborted 90 | const { gl, scene } = await Promise.race([ 91 | scenePromise, 92 | abortPromise.then(() => { 93 | throw new Error('aborted before scene is complete'); 94 | }) 95 | ]); 96 | 97 | // our own work manager (which is aware of the abort signal promise) 98 | const requestWork = createWorkManager( 99 | gl, 100 | abortPromise, 101 | settings.workPerFrame 102 | ); 103 | 104 | const workbench = await initializeWorkbench(scene, settings, requestWork); 105 | debugListeners?.onAtlasMap(workbench.atlasMap); // expose atlas map for debugging 106 | 107 | await withLightScene(workbench, async () => { 108 | await runBakingPasses(workbench, requestWork, (data, width, height) => { 109 | // expose current pass output for debugging 110 | debugListeners?.onPassComplete(data, width, height); 111 | }); 112 | }); 113 | 114 | return workbench; 115 | } 116 | 117 | // hook lifecycle for offscreen workflow 118 | export function useOffscreenWorkflow( 119 | content: React.ReactNode | null | undefined, 120 | settings?: OffscreenSettings, 121 | debugListeners?: Debug 122 | ) { 123 | // track the first reference to non-empty content 124 | const initialUsefulContentRef = useRef(content); 125 | initialUsefulContentRef.current = initialUsefulContentRef.current || content; 126 | 127 | // wrap latest value in ref to avoid triggering effect 128 | const settingsRef = useRef(settings); 129 | settingsRef.current = settings; 130 | 131 | const debugRef = useRef(debugListeners); 132 | debugRef.current = debugListeners; 133 | 134 | const [result, setResult] = useState(null); 135 | 136 | useLayoutEffect(() => { 137 | // @todo check if this runs multiple times on some React versions??? 138 | const children = initialUsefulContentRef.current; 139 | const settings = settingsRef.current; 140 | 141 | // set up abort signal promise 142 | let abortResolver = () => undefined as void; 143 | const abortPromise = new Promise((resolve) => { 144 | abortResolver = resolve; 145 | }); 146 | 147 | // run main logic with the abort signal promise 148 | if (children) { 149 | const workflowResult = runOffscreenWorkflow( 150 | children, 151 | settings ?? {}, 152 | abortPromise, 153 | debugRef.current 154 | ); 155 | 156 | workflowResult.then((result) => { 157 | setResult(result); 158 | }); 159 | } 160 | 161 | // on early unmount, resolve the abort signal promise 162 | return () => { 163 | abortResolver(); 164 | }; 165 | }, [initialUsefulContentRef.current]); 166 | 167 | // @todo clean up for direct consumption 168 | return result; 169 | } 170 | -------------------------------------------------------------------------------- /src/core/Lightmap.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2021-now Nick Matantsev 3 | * Licensed under the MIT license 4 | */ 5 | 6 | import React, { useState, useLayoutEffect, useRef } from 'react'; 7 | import * as THREE from 'three'; 8 | 9 | import { materialIsSupported } from './lightScene'; 10 | import { 11 | traverseSceneItems, 12 | WorkbenchSettings, 13 | LIGHTMAP_READONLY_FLAG, 14 | LIGHTMAP_IGNORE_FLAG 15 | } from './workbench'; 16 | import { computeAutoUV2Layout } from './AutoUV2'; 17 | import { useOffscreenWorkflow, Debug } from './offscreenWorkflow'; 18 | 19 | // prevent lightmap and UV2 generation for content 20 | // (but still allow contribution to lightmap, for e.g. emissive objects, large occluders, etc) 21 | export const LightmapReadOnly: React.FC<{ children: React.ReactNode }> = ({ 22 | children 23 | }) => { 24 | return ( 25 | 31 | {children} 32 | 33 | ); 34 | }; 35 | 36 | // prevent wrapped content from affecting the lightmap 37 | // (hide during baking so that this content does not contribute to irradiance) 38 | export const LightmapIgnore: React.FC<{ children: React.ReactNode }> = ({ 39 | children 40 | }) => { 41 | return ( 42 | 48 | {children} 49 | 50 | ); 51 | }; 52 | 53 | export interface DebugInfo { 54 | atlasTexture: THREE.Texture; 55 | outputTexture: THREE.Texture; 56 | } 57 | export const DebugContext = React.createContext(null); 58 | 59 | // set the computed irradiance texture on real scene materials 60 | function updateFinalSceneMaterials( 61 | scene: THREE.Scene, 62 | irradiance: THREE.Texture, 63 | aoMode: boolean 64 | ) { 65 | // process relevant meshes 66 | for (const object of traverseSceneItems(scene, false)) { 67 | // simple check for type (no need to check for uv2 presence) 68 | if (!(object instanceof THREE.Mesh)) { 69 | continue; 70 | } 71 | 72 | const mesh = object; 73 | 74 | const materialList: (THREE.Material | null)[] = Array.isArray(mesh.material) 75 | ? mesh.material 76 | : [mesh.material]; 77 | 78 | // fill in the computed maps 79 | materialList.forEach((material) => { 80 | if (!material || !materialIsSupported(material)) { 81 | return; 82 | } 83 | 84 | // set up our AO or lightmap as needed 85 | if (aoMode) { 86 | material.aoMap = irradiance; 87 | material.needsUpdate = true; 88 | } else { 89 | material.lightMap = irradiance; 90 | material.needsUpdate = true; 91 | } 92 | }); 93 | } 94 | } 95 | 96 | export type LightmapProps = WorkbenchSettings & { 97 | workPerFrame?: number; // @todo allow fractions, dynamic value 98 | disabled?: boolean; 99 | onComplete?: (result: THREE.Texture) => void; 100 | }; 101 | 102 | const Lightmap: React.FC> = ({ 103 | disabled, 104 | onComplete, 105 | children, 106 | ...settings 107 | }) => { 108 | // track latest reference to onComplete callback 109 | const onCompleteRef = useRef(onComplete); 110 | onCompleteRef.current = onComplete; 111 | 112 | // debug helper 113 | const [debugInfo, setDebugInfo] = useState(null); 114 | const debug: Debug = { 115 | onAtlasMap(atlasMap) { 116 | // initialize debug display of atlas texture as well as blank placeholder for output 117 | const atlasTexture = new THREE.DataTexture( 118 | atlasMap.data, 119 | atlasMap.width, 120 | atlasMap.height, 121 | THREE.RGBAFormat, 122 | THREE.FloatType 123 | ); 124 | 125 | const outputTexture = new THREE.DataTexture( 126 | new Float32Array(atlasMap.width * atlasMap.height * 4), 127 | atlasMap.width, 128 | atlasMap.height, 129 | THREE.RGBAFormat, 130 | THREE.FloatType 131 | ); 132 | 133 | setDebugInfo({ 134 | atlasTexture, 135 | outputTexture 136 | }); 137 | }, 138 | onPassComplete(data, width, height) { 139 | setDebugInfo( 140 | (prev) => 141 | prev && { 142 | ...prev, 143 | 144 | // replace with a new texture with copied source buffer data 145 | outputTexture: new THREE.DataTexture( 146 | new Float32Array(data), 147 | width, 148 | height, 149 | THREE.RGBAFormat, 150 | THREE.FloatType 151 | ) 152 | } 153 | ); 154 | } 155 | }; 156 | 157 | // main offscreen workflow state 158 | const result = useOffscreenWorkflow( 159 | disabled ? null : children, 160 | settings, 161 | debug 162 | ); 163 | 164 | const sceneRef = useRef(null); 165 | 166 | useLayoutEffect(() => { 167 | if (!result || !sceneRef.current) { 168 | return; 169 | } 170 | 171 | // create UV2 coordinates for the final scene meshes 172 | // @todo somehow reuse ones from the baker? 173 | computeAutoUV2Layout( 174 | [result.atlasMap.width, result.atlasMap.height], 175 | traverseSceneItems(sceneRef.current, true), 176 | { 177 | texelsPerUnit: result.texelsPerUnit 178 | } 179 | ); 180 | 181 | // copy texture data since this is coming from a foreign canvas 182 | const texture = result.createOutputTexture(); 183 | 184 | updateFinalSceneMaterials(sceneRef.current, texture, result.aoMode); 185 | 186 | // notify listener and pass the texture instance intended for parent GL context 187 | if (onCompleteRef.current) { 188 | onCompleteRef.current(texture); 189 | } 190 | }, [result]); 191 | 192 | // show final scene only when baking is done because it may contain loaded GLTF mesh instances 193 | // (which end up cached and reused, so only one scene can attach them at a time anyway) 194 | return ( 195 | 196 | {result ? ( 197 | 198 | {children} 199 | 200 | ) : null} 201 | 202 | ); 203 | }; 204 | 205 | export default Lightmap; 206 | -------------------------------------------------------------------------------- /src/core/workbench.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { renderAtlas, AtlasMap } from './atlas'; 4 | import { LightProbeSettings, DEFAULT_LIGHT_PROBE_SETTINGS } from './lightProbe'; 5 | import { computeAutoUV2Layout } from './AutoUV2'; 6 | 7 | export interface Workbench { 8 | aoMode: boolean; 9 | aoDistance: number; 10 | emissiveMultiplier: number; 11 | bounceMultiplier: number; 12 | texelsPerUnit: number; 13 | 14 | lightScene: THREE.Scene; 15 | atlasMap: AtlasMap; 16 | 17 | // lightmap output 18 | irradiance: THREE.Texture; 19 | irradianceData: Float32Array; 20 | 21 | createOutputTexture: () => THREE.Texture; 22 | 23 | // sampler settings 24 | settings: LightProbeSettings; 25 | } 26 | 27 | const DEFAULT_LIGHTMAP_SIZE = 64; 28 | const DEFAULT_TEXELS_PER_UNIT = 2; 29 | const DEFAULT_AO_DISTANCE = 3; 30 | 31 | // global conversion of display -> physical emissiveness 32 | // this is useful because emissive textures normally do not produce enough light to bounce to scene, 33 | // and simply increasing their emissiveIntensity would wash out the user-visible display colours 34 | const DEFAULT_EMISSIVE_MULTIPLIER = 32; 35 | 36 | // flags for marking up objects in scene 37 | // read-only flag allows preventing UV2 generation but can also allow objects that have UV2 and should be 38 | // in light scene, but are ignored in atlas and have their own lightmap 39 | export const LIGHTMAP_IGNORE_FLAG = Symbol('lightmap ignore flag'); 40 | export const LIGHTMAP_READONLY_FLAG = Symbol('lightmap read-only flag'); 41 | 42 | const hasOwnProp = Object.prototype.hasOwnProperty; 43 | export function objectHasFlag(object: THREE.Object3D, flag: symbol) { 44 | return hasOwnProp.call(object.userData, flag); 45 | } 46 | 47 | // hacky way to report current object flag while doing traverseSceneItems 48 | export let traversalStateIsReadOnly = false; 49 | 50 | // based on traverse() in https://github.com/mrdoob/three.js/blob/dev/src/core/Object3D.js 51 | export function* traverseSceneItems( 52 | root: THREE.Object3D, 53 | ignoreReadOnly?: boolean, 54 | onIgnored?: (object: THREE.Object3D) => void 55 | ) { 56 | const stack = [root]; 57 | const readOnlyStack = [false]; 58 | 59 | while (stack.length > 0) { 60 | const current = stack.pop()!; 61 | const inheritReadOnly = readOnlyStack.pop()!; 62 | 63 | // skip everything invisible and inside opt-out wrappers 64 | if ( 65 | !current.visible || 66 | objectHasFlag(current, LIGHTMAP_IGNORE_FLAG) || 67 | (ignoreReadOnly && objectHasFlag(current, LIGHTMAP_READONLY_FLAG)) 68 | ) { 69 | if (onIgnored) { 70 | onIgnored(current); 71 | } 72 | continue; 73 | } 74 | 75 | // compute readOnly flag for current object (either directly flagged or inheriting parent's flag) 76 | const activeReadOnly = 77 | inheritReadOnly || objectHasFlag(current, LIGHTMAP_READONLY_FLAG); 78 | 79 | // report to consumer 80 | traversalStateIsReadOnly = activeReadOnly; 81 | yield current; 82 | 83 | // recurse, letting children inherit the active/inherited readOnly flag 84 | for (const childObject of current.children) { 85 | stack.push(childObject); 86 | readOnlyStack.push(activeReadOnly); 87 | } 88 | } 89 | } 90 | 91 | function createRendererTexture( 92 | atlasWidth: number, 93 | atlasHeight: number, 94 | textureFilter: THREE.TextureFilter 95 | ): [THREE.Texture, Float32Array] { 96 | const atlasSize = atlasWidth * atlasHeight; 97 | const data = new Float32Array(4 * atlasSize); 98 | 99 | // not filling texture with test pattern because this goes right into light probe computation 100 | const texture = new THREE.DataTexture( 101 | data, 102 | atlasWidth, 103 | atlasHeight, 104 | THREE.RGBAFormat, 105 | THREE.FloatType 106 | ); 107 | 108 | // set desired texture filter (no mipmaps supported due to the nature of lightmaps) 109 | texture.magFilter = textureFilter; 110 | texture.minFilter = textureFilter; 111 | texture.generateMipmaps = false; 112 | 113 | return [texture, data]; 114 | } 115 | 116 | // @todo use work manager (though maybe not RAF based?) 117 | function requestNextTick() { 118 | return new Promise((resolve) => setTimeout(resolve, 0)); 119 | } 120 | 121 | export type SamplerSettings = Partial; 122 | export interface WorkbenchSettings { 123 | ao?: boolean; 124 | aoDistance?: number; 125 | emissiveMultiplier?: number; 126 | bounceMultiplier?: number; // crank up from 1 (default) to light up the corners 127 | lightMapSize?: number | [number, number]; 128 | textureFilter?: THREE.TextureFilter; 129 | texelsPerUnit?: number; 130 | samplerSettings?: SamplerSettings; 131 | } 132 | 133 | export async function initializeWorkbench( 134 | scene: THREE.Scene, 135 | props: WorkbenchSettings, 136 | requestWork: () => Promise 137 | ) { 138 | const { 139 | ao, 140 | aoDistance, 141 | emissiveMultiplier, 142 | bounceMultiplier, 143 | lightMapSize, 144 | textureFilter, 145 | texelsPerUnit, 146 | samplerSettings 147 | } = props; 148 | 149 | const settings = { 150 | ...DEFAULT_LIGHT_PROBE_SETTINGS, 151 | ...samplerSettings 152 | }; 153 | 154 | // wait a bit for responsiveness 155 | await requestNextTick(); 156 | 157 | // perform UV auto-layout in next tick 158 | const realTexelsPerUnit = texelsPerUnit || DEFAULT_TEXELS_PER_UNIT; 159 | 160 | const [computedWidth, computedHeight] = computeAutoUV2Layout( 161 | lightMapSize, 162 | traverseSceneItems(scene, true), 163 | { 164 | texelsPerUnit: realTexelsPerUnit 165 | } 166 | ); 167 | 168 | const lightMapWidth = computedWidth || DEFAULT_LIGHTMAP_SIZE; 169 | const lightMapHeight = computedHeight || DEFAULT_LIGHTMAP_SIZE; 170 | 171 | await requestNextTick(); 172 | 173 | // create renderer texture 174 | const [irradiance, irradianceData] = createRendererTexture( 175 | lightMapWidth, 176 | lightMapHeight, 177 | textureFilter || THREE.LinearFilter 178 | ); 179 | 180 | irradiance.name = 'Rendered irradiance map'; 181 | 182 | // perform atlas mapping 183 | const gl = await requestWork(); 184 | const atlasMap = renderAtlas( 185 | gl, 186 | lightMapWidth, 187 | lightMapHeight, 188 | traverseSceneItems(scene, true) 189 | ); 190 | 191 | // set up workbench 192 | return { 193 | aoMode: !!ao, 194 | aoDistance: aoDistance || DEFAULT_AO_DISTANCE, 195 | emissiveMultiplier: 196 | emissiveMultiplier === undefined 197 | ? DEFAULT_EMISSIVE_MULTIPLIER 198 | : emissiveMultiplier, 199 | bounceMultiplier: bounceMultiplier === undefined ? 1 : bounceMultiplier, 200 | texelsPerUnit: realTexelsPerUnit, 201 | 202 | lightScene: scene, 203 | atlasMap, 204 | 205 | irradiance, 206 | irradianceData, 207 | 208 | // clone the lightmap/AO map to use in a different GL context 209 | createOutputTexture(): THREE.Texture { 210 | const texture = new THREE.DataTexture( 211 | irradianceData, 212 | lightMapWidth, 213 | lightMapHeight, 214 | THREE.RGBAFormat, 215 | THREE.FloatType 216 | ); 217 | 218 | // set same texture filter (no mipmaps supported due to the nature of lightmaps) 219 | texture.magFilter = irradiance.magFilter; 220 | texture.minFilter = irradiance.minFilter; 221 | texture.generateMipmaps = false; 222 | 223 | return texture; 224 | }, 225 | 226 | settings: settings 227 | }; 228 | } 229 | -------------------------------------------------------------------------------- /src/core/lightScene.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | import { 3 | Workbench, 4 | traverseSceneItems, 5 | traversalStateIsReadOnly 6 | } from './workbench'; 7 | 8 | export type SupportedMaterial = 9 | | THREE.MeshLambertMaterial 10 | | THREE.MeshPhongMaterial 11 | | THREE.MeshStandardMaterial 12 | | THREE.MeshPhysicalMaterial; 13 | 14 | export function materialIsSupported( 15 | material: THREE.Material 16 | ): material is SupportedMaterial { 17 | return ( 18 | material instanceof THREE.MeshLambertMaterial || 19 | material instanceof THREE.MeshPhongMaterial || 20 | material instanceof THREE.MeshStandardMaterial || 21 | material instanceof THREE.MeshPhysicalMaterial 22 | ); 23 | } 24 | 25 | const ORIGINAL_MATERIAL_KEY = Symbol( 26 | 'lightmap baker: stashed original material' 27 | ); 28 | type UserDataStore = Record; 29 | 30 | // set up baking-friendly materials and clean up when done 31 | // (the cleanup is done in case the scene contains loaded GLTF mesh instances, 32 | // they will be re-attached from baking scene to final scene as is) 33 | export async function withLightScene( 34 | workbench: Workbench, 35 | taskCallback: () => Promise 36 | ) { 37 | // prepare the scene for baking 38 | const { 39 | aoMode, 40 | emissiveMultiplier, 41 | bounceMultiplier, 42 | lightScene, 43 | irradiance 44 | } = workbench; 45 | 46 | // process relevant meshes 47 | const meshCleanupList: THREE.Mesh[] = []; 48 | const suppressedCleanupList: THREE.Object3D[] = []; 49 | 50 | for (const object of traverseSceneItems( 51 | lightScene, 52 | false, 53 | (ignoredObject) => { 54 | // also prevent ignored items from rendering 55 | // (do nothing if already invisible to avoid setting it back to visible on cleanup) 56 | if (ignoredObject.visible) { 57 | ignoredObject.visible = false; 58 | suppressedCleanupList.push(ignoredObject); 59 | } 60 | } 61 | )) { 62 | // hide any visible lights to prevent interfering with AO 63 | if (aoMode && object instanceof THREE.Light) { 64 | object.visible = false; 65 | suppressedCleanupList.push(object); 66 | continue; 67 | } 68 | 69 | // simple check for type (no need to check for uv2 presence) 70 | if (!(object instanceof THREE.Mesh)) { 71 | continue; 72 | } 73 | 74 | const mesh = object; 75 | 76 | // for items with regular materials, temporarily replace the material with our 77 | // special "staging" material to be able to sub-in intermediate lightmap 78 | // texture during bounce passes 79 | // (checking against accidentally overriding some unrelated lightmap) 80 | // @todo allow developer to also flag certain custom materials as allowed 81 | const materialList: (THREE.Material | null)[] = Array.isArray(mesh.material) 82 | ? mesh.material 83 | : [mesh.material]; 84 | 85 | const stagingMaterialList = materialList.map((material) => { 86 | if (!material || !materialIsSupported(material)) { 87 | return material; 88 | } 89 | 90 | // basic safety check 91 | // @todo just hide these items, maybe with a warning 92 | if (aoMode) { 93 | if (material.aoMap && material.aoMap !== irradiance) { 94 | throw new Error( 95 | 'do not set your own AO map manually on baked scene meshes' 96 | ); 97 | } 98 | } else { 99 | if (material.lightMap && material.lightMap !== irradiance) { 100 | throw new Error( 101 | 'do not set your own light map manually on baked scene meshes' 102 | ); 103 | } 104 | } 105 | 106 | // clone sensible presentation properties 107 | const stagingMaterial = new THREE.MeshPhongMaterial(); 108 | stagingMaterial.name = 'Staging material'; 109 | stagingMaterial.alphaMap = material.alphaMap; 110 | stagingMaterial.alphaTest = material.alphaTest; 111 | if (!(material instanceof THREE.MeshLambertMaterial)) { 112 | stagingMaterial.displacementBias = material.displacementBias; 113 | stagingMaterial.displacementMap = material.displacementMap; 114 | stagingMaterial.displacementScale = material.displacementScale; 115 | stagingMaterial.flatShading = material.flatShading; 116 | } 117 | stagingMaterial.morphNormals = material.morphNormals; 118 | stagingMaterial.morphTargets = material.morphTargets; 119 | stagingMaterial.opacity = material.opacity; 120 | stagingMaterial.premultipliedAlpha = material.premultipliedAlpha; 121 | stagingMaterial.side = material.side; 122 | stagingMaterial.skinning = material.skinning; 123 | stagingMaterial.transparent = material.transparent; 124 | stagingMaterial.visible = material.visible; 125 | 126 | // in non-AO mode, also transfer pigmentation/emissive/other settings 127 | // (see below for AO/lightmap itself) 128 | if (!aoMode) { 129 | stagingMaterial.color = material.color; 130 | stagingMaterial.emissive = material.emissive; 131 | stagingMaterial.emissiveIntensity = 132 | material.emissiveIntensity * emissiveMultiplier; 133 | stagingMaterial.emissiveMap = material.emissiveMap; 134 | stagingMaterial.map = material.map; 135 | stagingMaterial.shadowSide = material.shadowSide; 136 | stagingMaterial.vertexColors = material.vertexColors; 137 | } 138 | 139 | // mandatory settings 140 | stagingMaterial.shininess = 0; // always fully diffuse 141 | stagingMaterial.toneMapped = false; // must output in raw linear space 142 | 143 | // deal with AO/lightmap 144 | if (traversalStateIsReadOnly) { 145 | // for read-only objects copy over pre-existing AO map 146 | stagingMaterial.aoMap = material.aoMap; 147 | stagingMaterial.aoMapIntensity = material.aoMapIntensity; 148 | 149 | // read-only objects might have their own lightmap, so we transfer that too 150 | // still also applying bounce multiplier for more light transmission 151 | // (but in AO mode ignore any lightmaps anyway) 152 | if (!aoMode) { 153 | stagingMaterial.lightMap = material.lightMap; 154 | stagingMaterial.lightMapIntensity = 155 | material.lightMapIntensity * bounceMultiplier; 156 | } 157 | } else { 158 | // set up our writable AO or lightmap as needed 159 | if (aoMode) { 160 | // @todo also respect bounce multiplier here (apply as inverse to AO intensity?) 161 | stagingMaterial.aoMap = irradiance; // use the AO texture 162 | } else { 163 | // use the lightmap texture 164 | stagingMaterial.lightMap = irradiance; 165 | 166 | // simply increase lightmap intensity for more bounce 167 | stagingMaterial.lightMapIntensity = bounceMultiplier; 168 | 169 | // also copy over any existing AO map 170 | stagingMaterial.aoMap = material.aoMap; 171 | stagingMaterial.aoMapIntensity = material.aoMapIntensity; 172 | } 173 | } 174 | 175 | return stagingMaterial; 176 | }); 177 | 178 | // stash original material list so that we can restore it later 179 | ( 180 | mesh.userData as UserDataStore< 181 | typeof ORIGINAL_MATERIAL_KEY, 182 | THREE.Material[] | THREE.Material 183 | > 184 | )[ORIGINAL_MATERIAL_KEY] = mesh.material; 185 | 186 | // assign updated list or single material 187 | mesh.material = Array.isArray(mesh.material) 188 | ? stagingMaterialList 189 | : stagingMaterialList[0]; 190 | 191 | // keep a simple list for later cleanup 192 | meshCleanupList.push(mesh); 193 | } 194 | 195 | let aoSceneLight: THREE.Light | null = null; 196 | if (aoMode) { 197 | // add our own ambient light for second pass of ambient occlusion 198 | // (this lights the texels unmasked by previous AO passes for further propagation) 199 | aoSceneLight = new THREE.AmbientLight('#ffffff'); 200 | lightScene.add(aoSceneLight); 201 | } 202 | 203 | // perform main task and then clean up regardless of error state 204 | try { 205 | await taskCallback(); 206 | } finally { 207 | // remove the staging ambient light 208 | if (aoSceneLight) { 209 | lightScene.remove(aoSceneLight); 210 | } 211 | 212 | // re-enable suppressed items (and lights if AO) 213 | suppressedCleanupList.forEach((object) => { 214 | object.visible = true; 215 | }); 216 | 217 | // replace staging material with original 218 | meshCleanupList.forEach((mesh) => { 219 | // get stashed material and clean up object key 220 | const userData = mesh.userData as UserDataStore< 221 | typeof ORIGINAL_MATERIAL_KEY, 222 | THREE.Material[] | THREE.Material | null 223 | >; 224 | const origMaterialValue = userData[ORIGINAL_MATERIAL_KEY]; 225 | delete userData[ORIGINAL_MATERIAL_KEY]; 226 | 227 | if (!origMaterialValue) { 228 | console.error('lightmap baker: missing original material', mesh); 229 | return; 230 | } 231 | 232 | // restore original setting 233 | mesh.material = origMaterialValue; 234 | }); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/core/atlas.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | import { ProbeTexel } from './lightProbe'; 4 | 5 | export interface AtlasMapItem { 6 | faceCount: number; 7 | originalMesh: THREE.Mesh; 8 | originalBuffer: THREE.BufferGeometry; 9 | } 10 | 11 | interface AtlasMapInternalItem extends AtlasMapItem { 12 | perFaceBuffer: THREE.BufferGeometry; 13 | } 14 | 15 | export interface AtlasMap { 16 | width: number; 17 | height: number; 18 | items: AtlasMapItem[]; 19 | data: Float32Array; 20 | } 21 | 22 | // must be black for full zeroing 23 | const ATLAS_BG_COLOR = new THREE.Color('#000000'); 24 | 25 | const VERTEX_SHADER = ` 26 | attribute vec4 faceInfo; 27 | attribute vec2 uv2; 28 | 29 | varying vec4 vFaceInfo; 30 | uniform vec2 uvOffset; 31 | 32 | void main() { 33 | vFaceInfo = faceInfo; 34 | 35 | gl_Position = projectionMatrix * vec4( 36 | uv2 + uvOffset, // UV2 is the actual position on map 37 | 0, 38 | 1.0 39 | ); 40 | } 41 | `; 42 | 43 | const FRAGMENT_SHADER = ` 44 | varying vec4 vFaceInfo; 45 | 46 | void main() { 47 | // encode the face information in map 48 | gl_FragColor = vFaceInfo; 49 | } 50 | `; 51 | 52 | function getTexelInfo( 53 | atlasMap: AtlasMap, 54 | texelIndex: number 55 | ): ProbeTexel | null { 56 | // get current atlas face we are filling up 57 | const texelInfoBase = texelIndex * 4; 58 | const texelPosU = atlasMap.data[texelInfoBase]; 59 | const texelPosV = atlasMap.data[texelInfoBase + 1]; 60 | const texelItemEnc = atlasMap.data[texelInfoBase + 2]; 61 | const texelFaceEnc = atlasMap.data[texelInfoBase + 3]; 62 | 63 | // skip computation if this texel is empty 64 | if (texelItemEnc === 0) { 65 | return null; 66 | } 67 | 68 | // otherwise, proceed with computation and exit 69 | const texelItemIndex = Math.round(texelItemEnc - 1); 70 | const texelFaceIndex = Math.round(texelFaceEnc - 1); 71 | 72 | if (texelItemIndex < 0 || texelItemIndex >= atlasMap.items.length) { 73 | throw new Error( 74 | `incorrect atlas map item data: ${texelPosU}, ${texelPosV}, ${texelItemEnc}, ${texelFaceEnc}` 75 | ); 76 | } 77 | 78 | const atlasItem = atlasMap.items[texelItemIndex]; 79 | 80 | if (texelFaceIndex < 0 || texelFaceIndex >= atlasItem.faceCount) { 81 | throw new Error( 82 | `incorrect atlas map face data: ${texelPosU}, ${texelPosV}, ${texelItemEnc}, ${texelFaceEnc}` 83 | ); 84 | } 85 | 86 | // report the viable texel to be baked 87 | // @todo reduce malloc? 88 | return { 89 | texelIndex, 90 | originalMesh: atlasItem.originalMesh, 91 | originalBuffer: atlasItem.originalBuffer, 92 | faceIndex: texelFaceIndex, 93 | pU: texelPosU, 94 | pV: texelPosV 95 | }; 96 | } 97 | 98 | // iterate through all texels 99 | export function* scanAtlasTexels(atlasMap: AtlasMap, onFinished: () => void) { 100 | const { width: atlasWidth, height: atlasHeight } = atlasMap; 101 | const totalTexelCount = atlasWidth * atlasHeight; 102 | 103 | let texelCount = 0; 104 | 105 | let retryCount = 0; 106 | while (texelCount < totalTexelCount) { 107 | // get current texel info and increment 108 | const currentCounter = texelCount; 109 | texelCount += 1; 110 | 111 | const texelInfo = getTexelInfo(atlasMap, currentCounter); 112 | 113 | // try to keep looking for a reasonable number of cycles 114 | // before yielding empty result 115 | if (!texelInfo && retryCount < 100) { 116 | retryCount += 1; 117 | continue; 118 | } 119 | 120 | // yield out with either a found texel or nothing 121 | retryCount = 0; 122 | yield texelInfo; 123 | } 124 | 125 | onFinished(); 126 | } 127 | 128 | // write out original face geometry info into the atlas map 129 | // each texel corresponds to: (quadX, quadY, quadIndex) 130 | // where quadX and quadY are 0..1 representing a spot in the original quad 131 | // and quadIndex is 1-based to distinguish from blank space 132 | // which allows to find original 3D position/normal/etc for that texel 133 | // (quad index is int stored as float, but precision should be good enough) 134 | // NOTE: each atlas texture sample corresponds to the position of 135 | // the physical midpoint of the corresponding rendered texel 136 | // (e.g. if lightmap was shown pixelated); this works well 137 | // with either bilinear or nearest filtering 138 | // @todo consider stencil buffer, or just 8bit texture 139 | 140 | function getInputItems(sceneItems: Generator) { 141 | const items = [] as AtlasMapInternalItem[]; 142 | 143 | for (const mesh of sceneItems) { 144 | if (!(mesh instanceof THREE.Mesh)) { 145 | continue; 146 | } 147 | 148 | // ignore anything that is not a buffer geometry 149 | // @todo warn on legacy geometry objects if they seem to have UV2? 150 | const buffer = mesh.geometry; 151 | if (!(buffer instanceof THREE.BufferGeometry)) { 152 | continue; 153 | } 154 | 155 | // if we see this object, it is not read-only and hence must have UV2 156 | const uv2Attr = buffer.attributes.uv2; 157 | if (!uv2Attr) { 158 | throw new Error('expecting UV2 coordinates on writable lightmapped mesh'); 159 | } 160 | 161 | // gather other necessary attributes and ensure compatible data 162 | // @todo support non-indexed meshes 163 | // @todo support interleaved attributes 164 | const indexAttr = buffer.index; 165 | if (!indexAttr) { 166 | throw new Error('expected face index array'); 167 | } 168 | 169 | const faceVertexCount = indexAttr.array.length; 170 | 171 | if (!(uv2Attr instanceof THREE.BufferAttribute)) { 172 | throw new Error('expected uv2 attribute'); 173 | } 174 | 175 | // index of this item once it will be added to list 176 | const itemIndex = items.length; 177 | 178 | const atlasPosAttr = new THREE.Float32BufferAttribute( 179 | faceVertexCount * 3, 180 | 3 181 | ); 182 | const atlasUV2Attr = new THREE.Float32BufferAttribute( 183 | faceVertexCount * 2, 184 | 2 185 | ); 186 | const atlasFaceInfoAttr = new THREE.Float32BufferAttribute( 187 | faceVertexCount * 4, 188 | 4 189 | ); 190 | 191 | // unroll indexed mesh data into non-indexed buffer so that we can encode per-face data 192 | // (otherwise vertices may be shared, and hence cannot have face-specific info in vertex attribute) 193 | const indexData = indexAttr.array; 194 | for ( 195 | let faceVertexIndex = 0; 196 | faceVertexIndex < faceVertexCount; 197 | faceVertexIndex += 1 198 | ) { 199 | const faceMod = faceVertexIndex % 3; 200 | 201 | // not bothering to copy vertex position data because we don't need it 202 | // (however, we cannot omit the 'position' attribute altogether) 203 | atlasUV2Attr.copyAt(faceVertexIndex, uv2Attr, indexData[faceVertexIndex]); 204 | 205 | // position of vertex in face: (0,0), (0,1) or (1,0) 206 | const facePosX = faceMod & 1; 207 | const facePosY = (faceMod & 2) >> 1; 208 | 209 | // mesh index + face index combined into one 210 | const faceIndex = (faceVertexIndex - faceMod) / 3; 211 | 212 | atlasFaceInfoAttr.setXYZW( 213 | faceVertexIndex, 214 | facePosX, 215 | facePosY, 216 | itemIndex + 1, // encode item index (1-based to indicate filled texels) 217 | faceIndex + 1 // encode face index (1-based to indicate filled texels) 218 | ); 219 | } 220 | 221 | // this buffer is disposed of when atlas scene is unmounted 222 | const atlasBuffer = new THREE.BufferGeometry(); 223 | atlasBuffer.setAttribute('position', atlasPosAttr); 224 | atlasBuffer.setAttribute('uv2', atlasUV2Attr); 225 | atlasBuffer.setAttribute('faceInfo', atlasFaceInfoAttr); 226 | 227 | items.push({ 228 | faceCount: faceVertexCount / 3, 229 | perFaceBuffer: atlasBuffer, 230 | originalMesh: mesh, 231 | originalBuffer: buffer 232 | }); 233 | } 234 | 235 | return items; 236 | } 237 | 238 | function createOrthoScene(inputItems: AtlasMapInternalItem[]) { 239 | const orthoScene = new THREE.Scene(); 240 | orthoScene.name = 'Atlas mapper ortho scene'; 241 | 242 | for (const geom of inputItems) { 243 | const mesh = new THREE.Mesh(); 244 | mesh.frustumCulled = false; // skip bounding box checks (not applicable and logic gets confused) 245 | 246 | mesh.geometry = geom.perFaceBuffer; 247 | mesh.material = new THREE.ShaderMaterial({ 248 | side: THREE.DoubleSide, 249 | 250 | vertexShader: VERTEX_SHADER, 251 | fragmentShader: FRAGMENT_SHADER 252 | }); 253 | 254 | orthoScene.add(mesh); 255 | } 256 | 257 | return orthoScene; 258 | } 259 | 260 | export function renderAtlas( 261 | gl: THREE.WebGLRenderer, 262 | width: number, 263 | height: number, 264 | sceneItems: Generator 265 | ) { 266 | const inputItems = getInputItems(sceneItems); 267 | const orthoScene = createOrthoScene(inputItems); 268 | 269 | // set up simple rasterization for pure data consumption 270 | const orthoTarget = new THREE.WebGLRenderTarget(width, height, { 271 | type: THREE.FloatType, 272 | magFilter: THREE.NearestFilter, 273 | minFilter: THREE.NearestFilter, 274 | depthBuffer: false, 275 | generateMipmaps: false 276 | }); 277 | 278 | const orthoCamera = new THREE.OrthographicCamera(0, 1, 1, 0, 0, 1); 279 | const orthoData = new Float32Array(width * height * 4); 280 | 281 | // save existing renderer state 282 | const prevClearColor = new THREE.Color(); 283 | gl.getClearColor(prevClearColor); 284 | const prevClearAlpha = gl.getClearAlpha(); 285 | const prevAutoClear = gl.autoClear; 286 | 287 | // produce the output 288 | gl.setRenderTarget(orthoTarget); 289 | 290 | gl.setClearColor(ATLAS_BG_COLOR, 0); // alpha must be zero 291 | gl.autoClear = true; 292 | 293 | gl.render(orthoScene, orthoCamera); 294 | 295 | // restore previous renderer state 296 | gl.setRenderTarget(null); 297 | gl.setClearColor(prevClearColor, prevClearAlpha); 298 | gl.autoClear = prevAutoClear; 299 | 300 | gl.readRenderTargetPixels(orthoTarget, 0, 0, width, height, orthoData); 301 | 302 | // clean up 303 | orthoTarget.dispose(); 304 | 305 | return { 306 | width: width, 307 | height: height, 308 | data: orthoData, 309 | 310 | // no need to expose references to atlas-specific geometry clones 311 | items: inputItems.map(({ faceCount, originalMesh, originalBuffer }) => ({ 312 | faceCount, 313 | originalMesh, 314 | originalBuffer 315 | })) 316 | }; 317 | } 318 | -------------------------------------------------------------------------------- /src/core/AutoUV2.tsx: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | /// 4 | import potpack, { PotPackItem } from 'potpack'; 5 | 6 | const tmpOrigin = new THREE.Vector3(); 7 | const tmpU = new THREE.Vector3(); 8 | const tmpV = new THREE.Vector3(); 9 | const tmpW = new THREE.Vector3(); 10 | 11 | const tmpNormal = new THREE.Vector3(); 12 | const tmpUAxis = new THREE.Vector3(); 13 | const tmpVAxis = new THREE.Vector3(); 14 | 15 | const tmpWLocal = new THREE.Vector2(); 16 | 17 | const tmpMinLocal = new THREE.Vector2(); 18 | const tmpMaxLocal = new THREE.Vector2(); 19 | 20 | // used for auto-indexing 21 | const tmpVert = new THREE.Vector3(); 22 | const tmpVert2 = new THREE.Vector3(); 23 | const tmpNormal2 = new THREE.Vector3(); 24 | 25 | function findVertex( 26 | posArray: ArrayLike, 27 | normalArray: ArrayLike, 28 | groupIndexArray: ArrayLike, 29 | vertexIndex: number 30 | ): number { 31 | tmpVert.fromArray(posArray, vertexIndex * 3); 32 | tmpNormal.fromArray(normalArray, vertexIndex * 3); 33 | const gi = groupIndexArray[vertexIndex]; 34 | 35 | // finish search before current vertex (since latter is the fallback return) 36 | for (let vStart = 0; vStart < vertexIndex; vStart += 1) { 37 | tmpVert2.fromArray(posArray, vStart * 3); 38 | tmpNormal2.fromArray(normalArray, vStart * 3); 39 | const gi2 = groupIndexArray[vStart]; 40 | 41 | if ( 42 | tmpVert2.equals(tmpVert) && 43 | tmpNormal2.equals(tmpNormal) && 44 | gi === gi2 45 | ) { 46 | return vStart; 47 | } 48 | } 49 | 50 | return vertexIndex; 51 | } 52 | 53 | function convertGeometryToIndexed(buffer: THREE.BufferGeometry) { 54 | const posArray = buffer.attributes.position.array; 55 | const posVertexCount = Math.floor(posArray.length / 3); 56 | const faceCount = Math.floor(posVertexCount / 3); 57 | 58 | const normalArray = buffer.attributes.normal.array; 59 | 60 | // fill out a group index lookup to keep faces separate by material 61 | const origGroups = buffer.groups || []; 62 | 63 | const groupIndexArray = new Array(posVertexCount); 64 | for (const group of origGroups) { 65 | groupIndexArray.fill( 66 | group.materialIndex, 67 | group.start, 68 | Math.min(posVertexCount, group.start + group.count) 69 | ); 70 | } 71 | 72 | const indexAttr = new THREE.Uint16BufferAttribute(faceCount * 3, 3); 73 | indexAttr.count = faceCount * 3; // @todo without this the mesh does not show all faces 74 | 75 | for (let faceIndex = 0; faceIndex < faceCount; faceIndex += 1) { 76 | const vStart = faceIndex * 3; 77 | const a = findVertex(posArray, normalArray, groupIndexArray, vStart); 78 | const b = findVertex(posArray, normalArray, groupIndexArray, vStart + 1); 79 | const c = findVertex(posArray, normalArray, groupIndexArray, vStart + 2); 80 | 81 | indexAttr.setXYZ(faceIndex, a, b, c); 82 | } 83 | 84 | buffer.setIndex(indexAttr); 85 | } 86 | 87 | function guessOrthogonalOrigin( 88 | indexArray: ArrayLike, 89 | vStart: number, 90 | posArray: ArrayLike 91 | ): number { 92 | let minAbsDot = 1; 93 | let minI = 0; 94 | 95 | for (let i = 0; i < 3; i += 1) { 96 | // for this ortho origin choice, compute defining edges 97 | tmpOrigin.fromArray(posArray, indexArray[vStart + i] * 3); 98 | tmpU.fromArray(posArray, indexArray[vStart + ((i + 2) % 3)] * 3); 99 | tmpV.fromArray(posArray, indexArray[vStart + ((i + 1) % 3)] * 3); 100 | 101 | tmpU.sub(tmpOrigin); 102 | tmpV.sub(tmpOrigin); 103 | 104 | // normalize and compute cross (cosine of angle) 105 | tmpU.normalize(); 106 | tmpV.normalize(); 107 | 108 | const absDot = Math.abs(tmpU.dot(tmpV)); 109 | 110 | // compare with current minimum 111 | if (minAbsDot > absDot) { 112 | minAbsDot = absDot; 113 | minI = i; 114 | } 115 | } 116 | 117 | return minI; 118 | } 119 | 120 | const MAX_AUTO_SIZE = 512; // @todo make this configurable? but 512x512 is a lot to compute already 121 | 122 | function autoSelectSize(layoutSize: number): number { 123 | // start with reasonable minimum size and keep trying increasing powers of 2 124 | for (let size = 4; size <= MAX_AUTO_SIZE; size *= 2) { 125 | if (layoutSize < size) { 126 | return size; 127 | } 128 | } 129 | 130 | throw new Error( 131 | `minimum lightmap dimension for auto-UV2 is ${layoutSize} which is too large: please reduce texelsPerUnit and/or polygon count` 132 | ); 133 | } 134 | 135 | interface AutoUVBox extends PotPackItem { 136 | uv2Attr: THREE.Float32BufferAttribute; 137 | 138 | uAxis: THREE.Vector3; 139 | vAxis: THREE.Vector3; 140 | 141 | posArray: ArrayLike; 142 | posIndices: number[]; 143 | posLocalX: number[]; 144 | posLocalY: number[]; 145 | } 146 | 147 | export interface AutoUV2Settings { 148 | texelsPerUnit: number; 149 | } 150 | 151 | export function computeAutoUV2Layout( 152 | lightMapSize: number | [number, number] | undefined, 153 | items: Generator, 154 | { texelsPerUnit }: AutoUV2Settings 155 | ): [number, number] { 156 | // parse the convenience setting 157 | const [initialWidth, initialHeight] = lightMapSize 158 | ? [ 159 | typeof lightMapSize === 'number' ? lightMapSize : lightMapSize[0], 160 | typeof lightMapSize === 'number' ? lightMapSize : lightMapSize[1] 161 | ] 162 | : [undefined, undefined]; 163 | 164 | const layoutBoxes: AutoUVBox[] = []; 165 | let hasPredefinedUV2 = false; 166 | 167 | for (const mesh of items) { 168 | if (!(mesh instanceof THREE.Mesh)) { 169 | continue; 170 | } 171 | 172 | const buffer = mesh.geometry; 173 | 174 | if (!(buffer instanceof THREE.BufferGeometry)) { 175 | throw new Error('expecting buffer geometry'); 176 | } 177 | 178 | // automatically convert to indexed 179 | if (!buffer.index) { 180 | convertGeometryToIndexed(buffer); 181 | } 182 | 183 | const indexAttr = buffer.index; 184 | if (!indexAttr) { 185 | throw new Error('unexpected missing geometry index attr'); 186 | } 187 | 188 | const indexArray = indexAttr.array; 189 | const faceCount = Math.floor(indexArray.length / 3); 190 | 191 | const posArray = buffer.attributes.position.array; 192 | const normalArray = buffer.attributes.normal.array; 193 | 194 | const vertexBoxMap: (AutoUVBox | undefined)[] = new Array( 195 | posArray.length / 3 196 | ); 197 | 198 | // complain if found predefined UV2 in a scene with computed UV2 199 | if (buffer.attributes.uv2) { 200 | if (layoutBoxes.length > 0) { 201 | throw new Error( 202 | 'found a mesh with "uv2" attribute in a scene with auto-calculated UV2 data: please do not mix-and-match' 203 | ); 204 | } 205 | 206 | hasPredefinedUV2 = true; 207 | continue; 208 | } 209 | 210 | // complain if trying to compute UV2 in a scene with predefined UV2 211 | if (hasPredefinedUV2) { 212 | throw new Error( 213 | 'found a mesh with missing "uv2" attribute in a scene with predefined UV2 data: please do not mix-and-match' 214 | ); 215 | } 216 | 217 | // pre-create uv2 attribute 218 | const uv2Attr = new THREE.Float32BufferAttribute( 219 | (2 * posArray.length) / 3, 220 | 2 221 | ); 222 | buffer.setAttribute('uv2', uv2Attr); 223 | 224 | for (let vStart = 0; vStart < faceCount * 3; vStart += 3) { 225 | // see if this face shares a vertex with an existing layout box 226 | let existingBox: AutoUVBox | undefined; 227 | 228 | for (let i = 0; i < 3; i += 1) { 229 | const possibleBox = vertexBoxMap[indexArray[vStart + i]]; 230 | 231 | if (!possibleBox) { 232 | continue; 233 | } 234 | 235 | if (existingBox && existingBox !== possibleBox) { 236 | // absorb layout box into the other 237 | // (this may happen if same polygon's faces are defined non-consecutively) 238 | existingBox.posIndices.push(...possibleBox.posIndices); 239 | existingBox.posLocalX.push(...possibleBox.posLocalX); 240 | existingBox.posLocalY.push(...possibleBox.posLocalY); 241 | 242 | // re-assign by-vertex lookup 243 | for (const index of possibleBox.posIndices) { 244 | vertexBoxMap[index] = existingBox; 245 | } 246 | 247 | // remove from main list 248 | const removedBoxIndex = layoutBoxes.indexOf(possibleBox); 249 | if (removedBoxIndex === -1) { 250 | throw new Error('unexpected orphaned layout box'); 251 | } 252 | layoutBoxes.splice(removedBoxIndex, 1); 253 | } else { 254 | existingBox = possibleBox; 255 | } 256 | } 257 | 258 | // set up new layout box if needed 259 | if (!existingBox) { 260 | // @todo guess axis choice based on angle? 261 | const originFI = guessOrthogonalOrigin(indexArray, vStart, posArray); 262 | 263 | const vOrigin = vStart + originFI; 264 | const vU = vStart + ((originFI + 2) % 3); // prev in face 265 | const vV = vStart + ((originFI + 1) % 3); // next in face 266 | 267 | // get the plane-defining edge vectors 268 | tmpOrigin.fromArray(posArray, indexArray[vOrigin] * 3); 269 | tmpU.fromArray(posArray, indexArray[vU] * 3); 270 | tmpV.fromArray(posArray, indexArray[vV] * 3); 271 | 272 | tmpU.sub(tmpOrigin); 273 | tmpV.sub(tmpOrigin); 274 | 275 | // compute orthogonal coordinate system for face plane 276 | tmpNormal.fromArray(normalArray, indexArray[vOrigin] * 3); 277 | tmpUAxis.crossVectors(tmpV, tmpNormal); 278 | tmpVAxis.crossVectors(tmpNormal, tmpUAxis); 279 | tmpUAxis.normalize(); 280 | tmpVAxis.normalize(); 281 | 282 | existingBox = { 283 | x: 0, // filled later 284 | y: 0, // filled later 285 | w: 0, // filled later 286 | h: 0, // filled later 287 | 288 | uv2Attr, 289 | 290 | uAxis: tmpUAxis.clone(), 291 | vAxis: tmpVAxis.clone(), 292 | 293 | posArray, 294 | posIndices: [], 295 | posLocalX: [], 296 | posLocalY: [] 297 | }; 298 | 299 | layoutBoxes.push(existingBox); 300 | } 301 | 302 | // add this face's vertices to the layout box local point set 303 | // @todo warn if normals deviate too much 304 | for (let i = 0; i < 3; i += 1) { 305 | const index = indexArray[vStart + i]; 306 | 307 | if (vertexBoxMap[index]) { 308 | continue; 309 | } 310 | 311 | vertexBoxMap[index] = existingBox; 312 | existingBox.posIndices.push(index); 313 | existingBox.posLocalX.push(0); // filled later 314 | existingBox.posLocalY.push(0); // filled later 315 | } 316 | } 317 | } 318 | 319 | // fill in local coords and compute dimensions for layout boxes based on polygon point sets inside them 320 | for (const layoutBox of layoutBoxes) { 321 | const { uAxis, vAxis, posArray, posIndices, posLocalX, posLocalY } = 322 | layoutBox; 323 | 324 | // compute min and max extents of all coords 325 | tmpMinLocal.set(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY); 326 | tmpMaxLocal.set(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY); 327 | 328 | for (let i = 0; i < posIndices.length; i += 1) { 329 | const index = posIndices[i]; 330 | 331 | tmpW.fromArray(posArray, index * 3); 332 | tmpWLocal.set(tmpW.dot(uAxis), tmpW.dot(vAxis)); 333 | 334 | tmpMinLocal.min(tmpWLocal); 335 | tmpMaxLocal.max(tmpWLocal); 336 | 337 | posLocalX[i] = tmpWLocal.x; 338 | posLocalY[i] = tmpWLocal.y; 339 | } 340 | 341 | const realWidth = tmpMaxLocal.x - tmpMinLocal.x; 342 | const realHeight = tmpMaxLocal.y - tmpMinLocal.y; 343 | 344 | if (realWidth < 0 || realHeight < 0) { 345 | throw new Error('zero-point polygon?'); 346 | } 347 | 348 | // texel box is aligned to texel grid 349 | const boxWidthInTexels = Math.ceil(realWidth * texelsPerUnit); 350 | const boxHeightInTexels = Math.ceil(realHeight * texelsPerUnit); 351 | 352 | // layout box positioning is in texels 353 | layoutBox.w = boxWidthInTexels + 2; // plus margins 354 | layoutBox.h = boxHeightInTexels + 2; // plus margins 355 | 356 | // make vertex local coords expressed as 0..1 inside texel box 357 | for (let i = 0; i < posIndices.length; i += 1) { 358 | posLocalX[i] = (posLocalX[i] - tmpMinLocal.x) / realWidth; 359 | posLocalY[i] = (posLocalY[i] - tmpMinLocal.y) / realHeight; 360 | } 361 | } 362 | 363 | // bail out if no layout is necessary 364 | if (layoutBoxes.length === 0) { 365 | return [initialWidth || 0, initialHeight || 0]; // report preferred size if given 366 | } 367 | 368 | // main layout magic 369 | const { w: layoutWidth, h: layoutHeight } = potpack(layoutBoxes); 370 | 371 | // check layout results against width/height if specified 372 | if ( 373 | (initialWidth && layoutWidth > initialWidth) || 374 | (initialHeight && layoutHeight > initialHeight) 375 | ) { 376 | throw new Error( 377 | `minimum lightmap size for auto-UV2 is ${layoutWidth}x${layoutHeight} which is too large to fit provided ${initialWidth}x${initialHeight}: please reduce texelsPerUnit and/or polygon count` 378 | ); 379 | } 380 | 381 | // auto-select sizing if needed 382 | const finalWidth = initialWidth || autoSelectSize(layoutWidth); 383 | const finalHeight = initialHeight || autoSelectSize(layoutHeight); 384 | 385 | // based on layout box positions, fill in UV2 attribute data 386 | for (const layoutBox of layoutBoxes) { 387 | const { x, y, w, h, uv2Attr, posIndices, posLocalX, posLocalY } = layoutBox; 388 | 389 | // inner texel box without margins 390 | const ix = x + 1; 391 | const iy = y + 1; 392 | const iw = w - 2; 393 | const ih = h - 2; 394 | 395 | // convert texel box placement into atlas UV coordinates 396 | for (let i = 0; i < posIndices.length; i += 1) { 397 | uv2Attr.setXY( 398 | posIndices[i], 399 | (ix + posLocalX[i] * iw) / finalWidth, 400 | (iy + posLocalY[i] * ih) / finalHeight 401 | ); 402 | } 403 | } 404 | 405 | // report final dimensions 406 | return [finalWidth, finalHeight]; 407 | } 408 | -------------------------------------------------------------------------------- /src/core/lightProbe.ts: -------------------------------------------------------------------------------- 1 | import * as THREE from 'three'; 2 | 3 | const tmpOrigin = new THREE.Vector3(); 4 | const tmpU = new THREE.Vector3(); 5 | const tmpV = new THREE.Vector3(); 6 | 7 | const tmpNormal = new THREE.Vector3(); 8 | const tmpLookAt = new THREE.Vector3(); 9 | 10 | const tmpProbeBox = new THREE.Vector4(); 11 | const tmpPrevClearColor = new THREE.Color(); 12 | 13 | // used inside blending function 14 | const tmpNormalOther = new THREE.Vector3(); 15 | 16 | const PROBE_BG_ZERO = new THREE.Color('#000000'); 17 | const PROBE_BG_FULL = new THREE.Color('#ffffff'); 18 | 19 | export const PROBE_BATCH_COUNT = 8; 20 | 21 | export interface LightProbeSettings { 22 | targetSize: number; 23 | offset: number; 24 | near: number; 25 | far: number; 26 | } 27 | 28 | export const DEFAULT_LIGHT_PROBE_SETTINGS: LightProbeSettings = { 29 | targetSize: 16, 30 | offset: 0, 31 | near: 0.05, 32 | far: 50 33 | }; 34 | 35 | export type ProbeDataReport = { 36 | rgbaData: Float32Array; 37 | rowPixelStride: number; 38 | probeBox: THREE.Vector4; 39 | originX: number; // device coordinates of lower-left corner of the viewbox 40 | originY: number; 41 | }; 42 | 43 | export interface ProbeTexel { 44 | texelIndex: number; // used by caller for correlation 45 | originalMesh: THREE.Mesh; 46 | originalBuffer: THREE.BufferGeometry; 47 | faceIndex: number; 48 | pU: number; 49 | pV: number; 50 | } 51 | 52 | export type ProbeBatcher = ( 53 | gl: THREE.WebGLRenderer, 54 | lightScene: THREE.Scene, 55 | texelIterator: Iterator 56 | ) => Generator<{ 57 | texelIndex: number; 58 | rgba: THREE.Vector4; 59 | }>; 60 | 61 | // bilinear interpolation of normals in triangle, with normalization 62 | function setBlendedNormal( 63 | out: THREE.Vector3, 64 | origNormalArray: ArrayLike, 65 | origIndexArray: ArrayLike, 66 | faceVertexBase: number, 67 | pU: number, 68 | pV: number 69 | ) { 70 | // barycentric coordinate for origin point 71 | const pO = 1 - pU - pV; 72 | 73 | out.fromArray(origNormalArray, origIndexArray[faceVertexBase] * 3); 74 | out.multiplyScalar(pO); 75 | 76 | tmpNormalOther.fromArray( 77 | origNormalArray, 78 | origIndexArray[faceVertexBase + 1] * 3 79 | ); 80 | out.addScaledVector(tmpNormalOther, pU); 81 | 82 | tmpNormalOther.fromArray( 83 | origNormalArray, 84 | origIndexArray[faceVertexBase + 2] * 3 85 | ); 86 | out.addScaledVector(tmpNormalOther, pV); 87 | 88 | out.normalize(); 89 | } 90 | 91 | function setUpProbeUp( 92 | probeCam: THREE.Camera, 93 | mesh: THREE.Mesh, 94 | origin: THREE.Vector3, 95 | normal: THREE.Vector3, 96 | uDir: THREE.Vector3 97 | ) { 98 | probeCam.position.copy(origin); 99 | 100 | probeCam.up.copy(uDir); 101 | 102 | // add normal to accumulator and look at it 103 | tmpLookAt.copy(normal); 104 | tmpLookAt.add(origin); 105 | 106 | probeCam.lookAt(tmpLookAt); 107 | probeCam.scale.set(1, 1, 1); 108 | 109 | // then, transform camera into world space 110 | probeCam.applyMatrix4(mesh.matrixWorld); 111 | } 112 | 113 | function setUpProbeSide( 114 | probeCam: THREE.Camera, 115 | mesh: THREE.Mesh, 116 | origin: THREE.Vector3, 117 | normal: THREE.Vector3, 118 | direction: THREE.Vector3, 119 | directionSign: number 120 | ) { 121 | probeCam.position.copy(origin); 122 | 123 | // up is the normal 124 | probeCam.up.copy(normal); 125 | 126 | // add normal to accumulator and look at it 127 | tmpLookAt.copy(origin); 128 | tmpLookAt.addScaledVector(direction, directionSign); 129 | 130 | probeCam.lookAt(tmpLookAt); 131 | probeCam.scale.set(1, 1, 1); 132 | 133 | // then, transform camera into world space 134 | probeCam.applyMatrix4(mesh.matrixWorld); 135 | } 136 | 137 | // for each pixel in the individual probe viewport, compute contribution to final tally 138 | // (edges are weaker because each pixel covers less of a view angle) 139 | // @todo perform weighted pixel averaging/etc all in this file 140 | export function generatePixelAreaLookup(probeTargetSize: number) { 141 | const probePixelCount = probeTargetSize * probeTargetSize; 142 | const lookup = new Array(probePixelCount) as number[]; 143 | 144 | const probePixelBias = 0.5 / probeTargetSize; 145 | 146 | for (let py = 0; py < probeTargetSize; py += 1) { 147 | // compute offset from center (with a bias for target pixel size) 148 | const dy = py / probeTargetSize - 0.5 + probePixelBias; 149 | 150 | for (let px = 0; px < probeTargetSize; px += 1) { 151 | // compute offset from center (with a bias for target pixel size) 152 | const dx = px / probeTargetSize - 0.5 + probePixelBias; 153 | 154 | // compute multiplier as affected by inclination of corresponding ray 155 | const span = Math.hypot(dx * 2, dy * 2); 156 | const hypo = Math.hypot(span, 1); 157 | const area = 1 / hypo; 158 | 159 | lookup[py * probeTargetSize + px] = area; 160 | } 161 | } 162 | 163 | return lookup; 164 | } 165 | 166 | // collect and combine pixel aggregate from rendered probe viewports 167 | // (this ignores the alpha channel from viewports) 168 | const tmpTexelRGBA = new THREE.Vector4(); 169 | 170 | function readTexel( 171 | readLightProbe: () => Generator, 172 | probePixelAreaLookup: number[] 173 | ) { 174 | let r = 0, 175 | g = 0, 176 | b = 0, 177 | totalDivider = 0; 178 | 179 | for (const { 180 | rgbaData: probeData, 181 | rowPixelStride, 182 | probeBox: box, 183 | originX, 184 | originY 185 | } of readLightProbe()) { 186 | const probeTargetSize = box.z; // assuming width is always full 187 | 188 | const rowStride = rowPixelStride * 4; 189 | let rowStart = box.y * rowStride + box.x * 4; 190 | const totalMax = (box.y + box.w) * rowStride; 191 | let py = originY; 192 | 193 | while (rowStart < totalMax) { 194 | const rowMax = rowStart + box.z * 4; 195 | let px = originX; 196 | 197 | for (let i = rowStart; i < rowMax; i += 4) { 198 | // compute multiplier as affected by inclination of corresponding ray 199 | const area = probePixelAreaLookup[py * probeTargetSize + px]; 200 | 201 | r += area * probeData[i]; 202 | g += area * probeData[i + 1]; 203 | b += area * probeData[i + 2]; 204 | 205 | totalDivider += area; 206 | 207 | px += 1; 208 | } 209 | 210 | rowStart += rowStride; 211 | py += 1; 212 | } 213 | } 214 | 215 | // alpha is set later 216 | tmpTexelRGBA.x = r / totalDivider; 217 | tmpTexelRGBA.y = g / totalDivider; 218 | tmpTexelRGBA.z = b / totalDivider; 219 | } 220 | 221 | // @todo use light sphere for AO (double-check that far-extent is radius + epsilon) 222 | export async function withLightProbe( 223 | aoMode: boolean, 224 | aoDistance: number, 225 | settings: LightProbeSettings, 226 | taskCallback: ( 227 | renderLightProbeBatch: ProbeBatcher, 228 | debugLightProbeTexture: THREE.Texture 229 | ) => Promise 230 | ) { 231 | const probeTargetSize = settings.targetSize; 232 | const probeBgColor = aoMode ? PROBE_BG_FULL : PROBE_BG_ZERO; 233 | 234 | const halfSize = probeTargetSize / 2; 235 | 236 | const targetWidth = probeTargetSize * 4; // 4 tiles across 237 | const targetHeight = probeTargetSize * 2 * PROBE_BATCH_COUNT; // 2 tiles x batch count 238 | 239 | // @todo make this async? 240 | const probePixelAreaLookup = generatePixelAreaLookup(probeTargetSize); 241 | 242 | // set up simple rasterization for pure data consumption 243 | const probeTarget = new THREE.WebGLRenderTarget(targetWidth, targetHeight, { 244 | type: THREE.FloatType, 245 | magFilter: THREE.NearestFilter, 246 | minFilter: THREE.NearestFilter, 247 | generateMipmaps: false 248 | }); 249 | 250 | const rtFov = 90; // view cone must be quarter of the hemisphere 251 | const rtAspect = 1; // square render target 252 | const rtNear = settings.near; 253 | const rtFar = aoMode ? aoDistance : settings.far; // in AO mode, lock far-extent to requested distance 254 | const probeCam = new THREE.PerspectiveCamera(rtFov, rtAspect, rtNear, rtFar); 255 | 256 | const probeData = new Float32Array(targetWidth * targetHeight * 4); 257 | 258 | const batchTexels = new Array(PROBE_BATCH_COUNT) as (number | undefined)[]; 259 | 260 | // @todo ensure there is biasing to be in middle of texel physical square 261 | const renderLightProbeBatch: ProbeBatcher = function* renderLightProbeBatch( 262 | gl, 263 | lightScene, 264 | texelIterator 265 | ) { 266 | // save existing renderer state 267 | gl.getClearColor(tmpPrevClearColor); 268 | const prevClearAlpha = gl.getClearAlpha(); 269 | const prevAutoClear = gl.autoClear; 270 | const prevToneMapping = gl.toneMapping; 271 | 272 | // reset tone mapping output to linear because we are aggregating unprocessed luminance output 273 | gl.toneMapping = THREE.LinearToneMapping; 274 | 275 | // set up render target for overall clearing 276 | // (bypassing setViewport means that the renderer conveniently preserves previous state) 277 | probeTarget.scissorTest = true; 278 | probeTarget.scissor.set(0, 0, targetWidth, targetHeight); 279 | probeTarget.viewport.set(0, 0, targetWidth, targetHeight); 280 | gl.setRenderTarget(probeTarget); 281 | gl.autoClear = false; 282 | 283 | // clear entire area 284 | gl.setClearColor(probeBgColor, 1); 285 | gl.clear(true, true, false); 286 | 287 | for (let batchItem = 0; batchItem < PROBE_BATCH_COUNT; batchItem += 1) { 288 | const texelResult = texelIterator.next(); 289 | 290 | if (!texelResult.done && texelResult.value) { 291 | const { texelIndex, originalMesh, originalBuffer, faceIndex, pU, pV } = 292 | texelResult.value; 293 | 294 | // each batch is 2 tiles high 295 | const batchOffsetY = batchItem * probeTargetSize * 2; 296 | 297 | // save which texel is being rendered for later reporting 298 | batchTexels[batchItem] = texelIndex; 299 | 300 | if (!originalBuffer.index) { 301 | throw new Error('expected indexed mesh'); 302 | } 303 | 304 | // read vertex position for this face and interpolate along U and V axes 305 | const origIndexArray = originalBuffer.index.array; 306 | const origPosArray = originalBuffer.attributes.position.array; 307 | const origNormalArray = originalBuffer.attributes.normal.array; 308 | 309 | // get face vertex positions 310 | const faceVertexBase = faceIndex * 3; 311 | tmpOrigin.fromArray(origPosArray, origIndexArray[faceVertexBase] * 3); 312 | tmpU.fromArray(origPosArray, origIndexArray[faceVertexBase + 1] * 3); 313 | tmpV.fromArray(origPosArray, origIndexArray[faceVertexBase + 2] * 3); 314 | 315 | // compute face dimensions 316 | tmpU.sub(tmpOrigin); 317 | tmpV.sub(tmpOrigin); 318 | 319 | // set camera to match texel, first in mesh-local space 320 | tmpOrigin.addScaledVector(tmpU, pU); 321 | tmpOrigin.addScaledVector(tmpV, pV); 322 | 323 | // compute normal and cardinal directions 324 | // (done per texel for linear interpolation of normals) 325 | setBlendedNormal( 326 | tmpNormal, 327 | origNormalArray, 328 | origIndexArray, 329 | faceVertexBase, 330 | pU, 331 | pV 332 | ); 333 | 334 | // use consistent "left" and "up" directions based on just the normal 335 | if (tmpNormal.x === 0 && tmpNormal.y === 0) { 336 | tmpU.set(1, 0, 0); 337 | } else { 338 | tmpU.set(0, 0, 1); 339 | } 340 | 341 | tmpV.crossVectors(tmpNormal, tmpU); 342 | tmpV.normalize(); 343 | 344 | tmpU.crossVectors(tmpNormal, tmpV); 345 | tmpU.normalize(); 346 | 347 | // nudge the light probe position based on requested offset 348 | tmpOrigin.addScaledVector(tmpNormal, settings.offset); 349 | 350 | // proceed with the renders 351 | setUpProbeUp(probeCam, originalMesh, tmpOrigin, tmpNormal, tmpU); 352 | probeTarget.viewport.set( 353 | 0, 354 | batchOffsetY + probeTargetSize, 355 | probeTargetSize, 356 | probeTargetSize 357 | ); 358 | probeTarget.scissor.set( 359 | 0, 360 | batchOffsetY + probeTargetSize, 361 | probeTargetSize, 362 | probeTargetSize 363 | ); 364 | gl.setRenderTarget(probeTarget); // propagate latest target params 365 | gl.render(lightScene, probeCam); 366 | 367 | // sides only need the upper half of rendered view, so we set scissor accordingly 368 | setUpProbeSide(probeCam, originalMesh, tmpOrigin, tmpNormal, tmpU, 1); 369 | probeTarget.viewport.set( 370 | 0, 371 | batchOffsetY, 372 | probeTargetSize, 373 | probeTargetSize 374 | ); 375 | probeTarget.scissor.set( 376 | 0, 377 | batchOffsetY + halfSize, 378 | probeTargetSize, 379 | halfSize 380 | ); 381 | gl.setRenderTarget(probeTarget); // propagate latest target params 382 | gl.render(lightScene, probeCam); 383 | 384 | setUpProbeSide(probeCam, originalMesh, tmpOrigin, tmpNormal, tmpU, -1); 385 | probeTarget.viewport.set( 386 | probeTargetSize, 387 | batchOffsetY, 388 | probeTargetSize, 389 | probeTargetSize 390 | ); 391 | probeTarget.scissor.set( 392 | probeTargetSize, 393 | batchOffsetY + halfSize, 394 | probeTargetSize, 395 | halfSize 396 | ); 397 | gl.setRenderTarget(probeTarget); // propagate latest target params 398 | gl.render(lightScene, probeCam); 399 | 400 | setUpProbeSide(probeCam, originalMesh, tmpOrigin, tmpNormal, tmpV, 1); 401 | probeTarget.viewport.set( 402 | probeTargetSize * 2, 403 | batchOffsetY, 404 | probeTargetSize, 405 | probeTargetSize 406 | ); 407 | probeTarget.scissor.set( 408 | probeTargetSize * 2, 409 | batchOffsetY + halfSize, 410 | probeTargetSize, 411 | halfSize 412 | ); 413 | gl.setRenderTarget(probeTarget); // propagate latest target params 414 | gl.render(lightScene, probeCam); 415 | 416 | setUpProbeSide(probeCam, originalMesh, tmpOrigin, tmpNormal, tmpV, -1); 417 | probeTarget.viewport.set( 418 | probeTargetSize * 3, 419 | batchOffsetY, 420 | probeTargetSize, 421 | probeTargetSize 422 | ); 423 | probeTarget.scissor.set( 424 | probeTargetSize * 3, 425 | batchOffsetY + halfSize, 426 | probeTargetSize, 427 | halfSize 428 | ); 429 | gl.setRenderTarget(probeTarget); // propagate latest target params 430 | gl.render(lightScene, probeCam); 431 | } else { 432 | // if nothing else to render, mark the end of batch and finish 433 | batchTexels[batchItem] = undefined; 434 | break; 435 | } 436 | } 437 | 438 | // fetch rendered data in one go (this is very slow) 439 | gl.readRenderTargetPixels( 440 | probeTarget, 441 | 0, 442 | 0, 443 | targetWidth, 444 | targetHeight, 445 | probeData 446 | ); 447 | 448 | // restore renderer state 449 | gl.setRenderTarget(null); // this restores original scissor/viewport 450 | gl.setClearColor(tmpPrevClearColor, prevClearAlpha); 451 | gl.autoClear = prevAutoClear; 452 | gl.toneMapping = prevToneMapping; 453 | 454 | // if something was rendered, send off the data for consumption 455 | for (let batchItem = 0; batchItem < PROBE_BATCH_COUNT; batchItem += 1) { 456 | const renderedTexelIndex = batchTexels[batchItem]; 457 | 458 | // see if the batch ended early 459 | if (renderedTexelIndex === undefined) { 460 | break; 461 | } 462 | 463 | // each batch is 2 tiles high 464 | const probePartsReporter = function* () { 465 | const batchOffsetY = batchItem * probeTargetSize * 2; 466 | const rowPixelStride = probeTargetSize * 4; 467 | 468 | const probeDataReport: ProbeDataReport = { 469 | rgbaData: probeData, 470 | rowPixelStride, 471 | probeBox: tmpProbeBox, 472 | originX: 0, // filled in later 473 | originY: 0 474 | }; 475 | 476 | tmpProbeBox.set( 477 | 0, 478 | batchOffsetY + probeTargetSize, 479 | probeTargetSize, 480 | probeTargetSize 481 | ); 482 | probeDataReport.originX = 0; 483 | probeDataReport.originY = 0; 484 | yield probeDataReport; 485 | 486 | tmpProbeBox.set(0, batchOffsetY + halfSize, probeTargetSize, halfSize); 487 | probeDataReport.originX = 0; 488 | probeDataReport.originX = halfSize; 489 | yield probeDataReport; 490 | 491 | tmpProbeBox.set( 492 | probeTargetSize, 493 | batchOffsetY + halfSize, 494 | probeTargetSize, 495 | halfSize 496 | ); 497 | probeDataReport.originX = 0; 498 | probeDataReport.originX = halfSize; 499 | yield probeDataReport; 500 | 501 | tmpProbeBox.set( 502 | probeTargetSize * 2, 503 | batchOffsetY + halfSize, 504 | probeTargetSize, 505 | halfSize 506 | ); 507 | probeDataReport.originX = 0; 508 | probeDataReport.originX = halfSize; 509 | yield probeDataReport; 510 | 511 | tmpProbeBox.set( 512 | probeTargetSize * 3, 513 | batchOffsetY + halfSize, 514 | probeTargetSize, 515 | halfSize 516 | ); 517 | probeDataReport.originX = 0; 518 | probeDataReport.originX = halfSize; 519 | yield probeDataReport; 520 | }; 521 | 522 | // aggregate the probe target pixels 523 | readTexel(probePartsReporter, probePixelAreaLookup); 524 | 525 | yield { texelIndex: renderedTexelIndex, rgba: tmpTexelRGBA }; 526 | } 527 | }; 528 | 529 | try { 530 | await taskCallback(renderLightProbeBatch, probeTarget.texture); 531 | } finally { 532 | // always clean up regardless of error state 533 | probeTarget.dispose(); 534 | } 535 | } 536 | --------------------------------------------------------------------------------