rayzee 6.3.0 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/rayzee.es.js +3314 -1861
- package/dist/rayzee.es.js.map +1 -1
- package/dist/rayzee.umd.js +27 -25
- package/dist/rayzee.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/EngineDefaults.js +17 -4
- package/src/PathTracerApp.js +1 -0
- package/src/Processor/AssetLoader.js +151 -1
- package/src/Processor/GeometryExtractor.js +16 -1
- package/src/Processor/PBRT/PBRTMaterials.js +350 -0
- package/src/Processor/PBRT/PBRTMath.js +173 -0
- package/src/Processor/PBRT/PBRTParser.js +642 -0
- package/src/Processor/PBRT/PBRTSceneBuilder.js +578 -0
- package/src/Processor/PBRT/PBRTTokenizer.js +141 -0
- package/src/Processor/PBRT/index.js +179 -0
- package/src/Processor/ShaderBuilder.js +1 -0
- package/src/Processor/TextureCreator.js +6 -0
- package/src/RenderSettings.js +1 -0
- package/src/TSL/BVHTraversal.js +7 -1
- package/src/TSL/Common.js +12 -2
- package/src/TSL/MaterialTransmission.js +32 -2
- package/src/TSL/PathTracer.js +2 -2
- package/src/TSL/PathTracerCore.js +151 -12
- package/src/TSL/Struct.js +5 -0
- package/src/TSL/Subsurface.js +232 -0
- package/src/managers/MaterialDataManager.js +60 -1
- package/src/managers/UniformManager.js +1 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a parsed pbrt IR into a THREE scene graph the engine can ingest.
|
|
3
|
+
*
|
|
4
|
+
* Output: { group, camera, environment, warnings }
|
|
5
|
+
* - group: THREE.Group of meshes (fed to PathTracerApp.loadObject3D)
|
|
6
|
+
* - camera: PerspectiveCamera matching the pbrt Camera/LookAt, parented
|
|
7
|
+
* into the group so AssetLoader.extractCamerasFromModel finds it
|
|
8
|
+
* - environment: { texture } | null — set by the caller as scene.environment
|
|
9
|
+
*
|
|
10
|
+
* Handedness: pbrt scenes import correctly as-is. A `diag(1,1,-1)` mirror is
|
|
11
|
+
* available behind `convertHandedness` (default OFF) — three's `lookAt` builds
|
|
12
|
+
* a correct camera basis regardless of source handedness, so no mirror is
|
|
13
|
+
* needed. Enable only if a scene comes out z-mirrored against a known reference.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
Group, Mesh, PerspectiveCamera, Matrix4, Vector3,
|
|
18
|
+
BufferGeometry, Float32BufferAttribute, SphereGeometry,
|
|
19
|
+
DataTexture, FloatType, RGBAFormat, LinearFilter, EquirectangularReflectionMapping
|
|
20
|
+
} from 'three';
|
|
21
|
+
import { buildMaterial, pFloat, pString, resolveSpectrum } from './PBRTMaterials.js';
|
|
22
|
+
import * as M from './PBRTMath.js';
|
|
23
|
+
|
|
24
|
+
const FLIP_Z = [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, - 1, 0, 0, 0, 0, 1 ];
|
|
25
|
+
|
|
26
|
+
export class PBRTSceneBuilder {
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} resolvers
|
|
30
|
+
* @param {(filename:string)=>Promise<BufferGeometry>} [resolvers.resolvePLY]
|
|
31
|
+
* @param {(filename:string)=>Promise<import('three').Texture>} [resolvers.resolveImage]
|
|
32
|
+
* @param {(filename:string)=>Promise<import('three').Texture>} [resolvers.resolveEnvironment]
|
|
33
|
+
* @param {boolean} [resolvers.convertHandedness=false]
|
|
34
|
+
*/
|
|
35
|
+
constructor( resolvers = {} ) {
|
|
36
|
+
|
|
37
|
+
this.resolvePLY = resolvers.resolvePLY || ( async () => null );
|
|
38
|
+
this.resolveImage = resolvers.resolveImage || ( async () => null );
|
|
39
|
+
this.resolveEnvironment = resolvers.resolveEnvironment || resolvers.resolveImage || ( async () => null );
|
|
40
|
+
this.convertHandedness = resolvers.convertHandedness === true;
|
|
41
|
+
|
|
42
|
+
this.warnings = [];
|
|
43
|
+
this.report = []; // per-mesh diagnostics for debugging imports
|
|
44
|
+
this._materialCache = new Map(); // material obj -> Map(areaLight obj -> MeshPhysicalMaterial)
|
|
45
|
+
this._textureCache = new Map(); // texName -> { texture } | { constant }
|
|
46
|
+
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
warn( msg ) {
|
|
50
|
+
|
|
51
|
+
this.warnings.push( msg );
|
|
52
|
+
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} ir - output of PBRTParser
|
|
57
|
+
* @returns {Promise<{group:Group, camera:PerspectiveCamera|null, environment:object|null, warnings:string[]}>}
|
|
58
|
+
*/
|
|
59
|
+
async build( ir ) {
|
|
60
|
+
|
|
61
|
+
this.ir = ir;
|
|
62
|
+
const group = new Group();
|
|
63
|
+
group.name = 'PBRTScene';
|
|
64
|
+
|
|
65
|
+
// Shapes (direct + instanced)
|
|
66
|
+
for ( let i = 0; i < ir.shapes.length; i ++ ) {
|
|
67
|
+
|
|
68
|
+
const mesh = await this._buildShapeMesh( ir.shapes[ i ], ir.shapes[ i ].ctm, `shape_${i}` );
|
|
69
|
+
if ( mesh ) group.add( mesh );
|
|
70
|
+
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await this._buildInstances( ir, group );
|
|
74
|
+
|
|
75
|
+
// Camera
|
|
76
|
+
let camera = null;
|
|
77
|
+
if ( ir.camera ) {
|
|
78
|
+
|
|
79
|
+
camera = this._buildCamera( ir.camera, ir.film );
|
|
80
|
+
if ( camera ) group.add( camera );
|
|
81
|
+
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Infinite light → environment
|
|
85
|
+
const environment = await this._buildEnvironment( ir.lights );
|
|
86
|
+
|
|
87
|
+
this._reportUnsupportedLights( ir.lights );
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
group, camera, environment,
|
|
91
|
+
report: this.report,
|
|
92
|
+
warnings: this.warnings.concat( ir.warnings || [] )
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── shapes ─────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async _buildInstances( ir, group ) {
|
|
100
|
+
|
|
101
|
+
let n = 0;
|
|
102
|
+
for ( const inst of ir.instances ) {
|
|
103
|
+
|
|
104
|
+
const template = ir.objects.get( inst.name );
|
|
105
|
+
if ( ! template ) {
|
|
106
|
+
|
|
107
|
+
this.warn( `ObjectInstance "${inst.name}" has no template` ); continue;
|
|
108
|
+
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for ( const shape of template ) {
|
|
112
|
+
|
|
113
|
+
// instance placement: instanceCTM * (shape relative to its ObjectBegin frame)
|
|
114
|
+
const worldCTM = M.multiply( inst.ctm, shape.relativeCTM || shape.ctm );
|
|
115
|
+
const mesh = await this._buildShapeMesh( shape, worldCTM, `instance_${n ++}` );
|
|
116
|
+
if ( mesh ) group.add( mesh );
|
|
117
|
+
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async _buildShapeMesh( shape, ctm, name ) {
|
|
125
|
+
|
|
126
|
+
// Geometry parse and material/texture resolution are independent — overlap them.
|
|
127
|
+
const [ geometry, sharedMaterial ] = await Promise.all( [
|
|
128
|
+
this._buildGeometry( shape ),
|
|
129
|
+
this._getMaterial( shape )
|
|
130
|
+
] );
|
|
131
|
+
if ( ! geometry ) return null;
|
|
132
|
+
|
|
133
|
+
// A textured material on geometry with no UVs samples a single texel — the
|
|
134
|
+
// usual cause of "black"/wrong meshes on import. Drop the map, but on a CLONE:
|
|
135
|
+
// _getMaterial caches and shares one instance across every shape using the
|
|
136
|
+
// same NamedMaterial, so mutating it would strip the texture from sibling
|
|
137
|
+
// meshes that DO have UVs.
|
|
138
|
+
const hasUV = !! geometry.getAttribute( 'uv' );
|
|
139
|
+
let material = sharedMaterial;
|
|
140
|
+
if ( sharedMaterial.map && ! hasUV ) {
|
|
141
|
+
|
|
142
|
+
this.warn( `${name} (${shape.type}, "${shape.material?.type || 'diffuse'}") has a texture map but no UVs — dropping map, using base color` );
|
|
143
|
+
material = sharedMaterial.clone();
|
|
144
|
+
material.map = null;
|
|
145
|
+
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const mesh = new Mesh( geometry, material );
|
|
149
|
+
mesh.name = name;
|
|
150
|
+
|
|
151
|
+
// Apply the world transform via TRS, NOT a direct mesh.matrix assignment:
|
|
152
|
+
// GeometryExtractor calls mesh.updateMatrix(), which recomposes the matrix
|
|
153
|
+
// from position/quaternion/scale. A directly-set matrix gets overwritten
|
|
154
|
+
// with identity there — silently dropping every per-shape Transform.
|
|
155
|
+
// decompose() round-trips the handedness mirror (det<0) via a negative scale axis.
|
|
156
|
+
const world = this.convertHandedness ? M.multiply( FLIP_Z, ctm ) : ctm;
|
|
157
|
+
new Matrix4().fromArray( world ).decompose( mesh.position, mesh.quaternion, mesh.scale );
|
|
158
|
+
mesh.updateMatrix();
|
|
159
|
+
|
|
160
|
+
// World-space dimensions (object bbox transformed by the baked matrix) —
|
|
161
|
+
// surfaces an oversized/under-scaled mesh at a glance.
|
|
162
|
+
geometry.computeBoundingBox();
|
|
163
|
+
const worldSize = geometry.boundingBox
|
|
164
|
+
? geometry.boundingBox.clone().applyMatrix4( mesh.matrix ).getSize( new Vector3() )
|
|
165
|
+
: new Vector3();
|
|
166
|
+
|
|
167
|
+
this.report.push( {
|
|
168
|
+
mesh: name,
|
|
169
|
+
shape: shape.type,
|
|
170
|
+
material: shape.material?.type || 'diffuse',
|
|
171
|
+
color: '#' + material.color.getHexString(),
|
|
172
|
+
map: material.map ? 'yes' : '-',
|
|
173
|
+
uv: hasUV ? 'yes' : 'NO',
|
|
174
|
+
normals: geometry.getAttribute( 'normal' ) ? 'yes' : 'NO',
|
|
175
|
+
emissive: material.emissiveIntensity > 0 ? `#${material.emissive.getHexString()}×${material.emissiveIntensity}` : '-',
|
|
176
|
+
size: `${worldSize.x.toFixed( 2 )}×${worldSize.y.toFixed( 2 )}×${worldSize.z.toFixed( 2 )}`,
|
|
177
|
+
tris: geometry.index ? geometry.index.count / 3 : geometry.getAttribute( 'position' ).count / 3
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
return mesh;
|
|
181
|
+
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async _buildGeometry( shape ) {
|
|
185
|
+
|
|
186
|
+
switch ( shape.type ) {
|
|
187
|
+
|
|
188
|
+
case 'trianglemesh': return this._triangleMesh( shape.params );
|
|
189
|
+
case 'bilinearmesh': return this._bilinearMesh( shape.params );
|
|
190
|
+
case 'plymesh': return this._plyMesh( shape.params );
|
|
191
|
+
case 'sphere': return this._sphere( shape.params );
|
|
192
|
+
case 'disk': return this._disk( shape.params );
|
|
193
|
+
default:
|
|
194
|
+
this.warn( `shape "${shape.type}" not supported — skipped` );
|
|
195
|
+
return null;
|
|
196
|
+
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_triangleMesh( params ) {
|
|
202
|
+
|
|
203
|
+
const P = params.P?.value;
|
|
204
|
+
if ( ! P || P.length < 9 ) {
|
|
205
|
+
|
|
206
|
+
this.warn( 'trianglemesh missing P' ); return null;
|
|
207
|
+
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const geo = new BufferGeometry();
|
|
211
|
+
geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( P ), 3 ) );
|
|
212
|
+
|
|
213
|
+
const N = params.N?.value;
|
|
214
|
+
if ( N && N.length === P.length ) geo.setAttribute( 'normal', new Float32BufferAttribute( Float32Array.from( N ), 3 ) );
|
|
215
|
+
|
|
216
|
+
const uv = ( params.uv || params.st )?.value;
|
|
217
|
+
if ( uv && uv.length === ( P.length / 3 ) * 2 ) geo.setAttribute( 'uv', new Float32BufferAttribute( Float32Array.from( uv ), 2 ) );
|
|
218
|
+
|
|
219
|
+
const indices = params.indices?.value;
|
|
220
|
+
if ( indices && indices.length ) geo.setIndex( indices );
|
|
221
|
+
|
|
222
|
+
if ( ! N ) geo.computeVertexNormals();
|
|
223
|
+
return geo;
|
|
224
|
+
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Bilinear patch mesh → triangulate each quad (P + indices in quads of 4).
|
|
228
|
+
_bilinearMesh( params ) {
|
|
229
|
+
|
|
230
|
+
const P = params.P?.value;
|
|
231
|
+
const quad = params.indices?.value;
|
|
232
|
+
if ( ! P || ! quad ) {
|
|
233
|
+
|
|
234
|
+
this.warn( 'bilinearmesh missing P/indices' ); return null;
|
|
235
|
+
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tris = [];
|
|
239
|
+
for ( let i = 0; i + 3 < quad.length; i += 4 ) {
|
|
240
|
+
|
|
241
|
+
const [ a, b, c, d ] = [ quad[ i ], quad[ i + 1 ], quad[ i + 2 ], quad[ i + 3 ] ];
|
|
242
|
+
tris.push( a, b, c, a, c, d );
|
|
243
|
+
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const geo = new BufferGeometry();
|
|
247
|
+
geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( P ), 3 ) );
|
|
248
|
+
geo.setIndex( tris );
|
|
249
|
+
geo.computeVertexNormals();
|
|
250
|
+
return geo;
|
|
251
|
+
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async _plyMesh( params ) {
|
|
255
|
+
|
|
256
|
+
const filename = pString( params, 'filename', null );
|
|
257
|
+
if ( ! filename ) {
|
|
258
|
+
|
|
259
|
+
this.warn( 'plymesh missing filename' ); return null;
|
|
260
|
+
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
|
|
265
|
+
const geo = await this.resolvePLY( filename );
|
|
266
|
+
if ( ! geo ) {
|
|
267
|
+
|
|
268
|
+
this.warn( `plymesh file not found: ${filename}` ); return null;
|
|
269
|
+
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if ( ! geo.getAttribute( 'normal' ) ) geo.computeVertexNormals();
|
|
273
|
+
return geo;
|
|
274
|
+
|
|
275
|
+
} catch ( e ) {
|
|
276
|
+
|
|
277
|
+
this.warn( `failed to load plymesh ${filename}: ${e.message}` );
|
|
278
|
+
return null;
|
|
279
|
+
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
_sphere( params ) {
|
|
285
|
+
|
|
286
|
+
const radius = pFloat( params, 'radius', 1 );
|
|
287
|
+
return new SphereGeometry( radius, 48, 32 );
|
|
288
|
+
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_disk( params ) {
|
|
292
|
+
|
|
293
|
+
// Approximate as a thin ring/disk in the z=height plane.
|
|
294
|
+
const radius = pFloat( params, 'radius', 1 );
|
|
295
|
+
const inner = pFloat( params, 'innerradius', 0 );
|
|
296
|
+
const h = pFloat( params, 'height', 0 );
|
|
297
|
+
const seg = 48;
|
|
298
|
+
const pos = [];
|
|
299
|
+
const idx = [];
|
|
300
|
+
for ( let i = 0; i < seg; i ++ ) {
|
|
301
|
+
|
|
302
|
+
const a0 = ( i / seg ) * Math.PI * 2;
|
|
303
|
+
const a1 = ( ( i + 1 ) / seg ) * Math.PI * 2;
|
|
304
|
+
const base = pos.length / 3;
|
|
305
|
+
pos.push( Math.cos( a0 ) * inner, Math.sin( a0 ) * inner, h );
|
|
306
|
+
pos.push( Math.cos( a0 ) * radius, Math.sin( a0 ) * radius, h );
|
|
307
|
+
pos.push( Math.cos( a1 ) * radius, Math.sin( a1 ) * radius, h );
|
|
308
|
+
pos.push( Math.cos( a1 ) * inner, Math.sin( a1 ) * inner, h );
|
|
309
|
+
idx.push( base, base + 1, base + 2, base, base + 2, base + 3 );
|
|
310
|
+
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const geo = new BufferGeometry();
|
|
314
|
+
geo.setAttribute( 'position', new Float32BufferAttribute( Float32Array.from( pos ), 3 ) );
|
|
315
|
+
geo.setIndex( idx );
|
|
316
|
+
geo.computeVertexNormals();
|
|
317
|
+
return geo;
|
|
318
|
+
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ── materials (with area-light emission) ───────────────────────
|
|
322
|
+
|
|
323
|
+
async _getMaterial( shape ) {
|
|
324
|
+
|
|
325
|
+
// Cache by (material, areaLight) object identity — many shapes share a
|
|
326
|
+
// NamedMaterial, so this dedupes the build + texture-decode work. Nested
|
|
327
|
+
// Map keys on the object refs directly (null is a valid key).
|
|
328
|
+
let byLight = this._materialCache.get( shape.material );
|
|
329
|
+
if ( ! byLight ) {
|
|
330
|
+
|
|
331
|
+
byLight = new Map();
|
|
332
|
+
this._materialCache.set( shape.material, byLight );
|
|
333
|
+
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if ( byLight.has( shape.areaLight ) ) return byLight.get( shape.areaLight );
|
|
337
|
+
|
|
338
|
+
const ctx = {
|
|
339
|
+
resolveNamedTexture: ( n ) => this._resolveNamedTexture( n ),
|
|
340
|
+
namedMaterials: this.ir.namedMaterials,
|
|
341
|
+
warn: ( m ) => this.warn( m )
|
|
342
|
+
};
|
|
343
|
+
const material = await buildMaterial( shape.material, ctx );
|
|
344
|
+
|
|
345
|
+
if ( shape.areaLight ) await this._applyAreaLight( material, shape.areaLight, ctx );
|
|
346
|
+
|
|
347
|
+
byLight.set( shape.areaLight, material );
|
|
348
|
+
return material;
|
|
349
|
+
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async _applyAreaLight( material, areaLight, ctx ) {
|
|
353
|
+
|
|
354
|
+
const L = await resolveSpectrum( areaLight.params, 'L', ctx, [ 1, 1, 1 ] );
|
|
355
|
+
const scale = pFloat( areaLight.params, 'scale', 1 );
|
|
356
|
+
const rgb = L.rgb || [ 1, 1, 1 ];
|
|
357
|
+
material.emissive.setRGB( rgb[ 0 ], rgb[ 1 ], rgb[ 2 ] );
|
|
358
|
+
material.emissiveIntensity = scale;
|
|
359
|
+
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async _resolveNamedTexture( name ) {
|
|
363
|
+
|
|
364
|
+
if ( this._textureCache.has( name ) ) return this._textureCache.get( name );
|
|
365
|
+
|
|
366
|
+
const def = this.ir.namedTextures.get( name );
|
|
367
|
+
let result = null;
|
|
368
|
+
|
|
369
|
+
if ( ! def ) {
|
|
370
|
+
|
|
371
|
+
this.warn( `named texture "${name}" not defined` );
|
|
372
|
+
|
|
373
|
+
} else if ( def.class === 'imagemap' ) {
|
|
374
|
+
|
|
375
|
+
const filename = pString( def.params, 'filename', null );
|
|
376
|
+
if ( filename ) {
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
|
|
380
|
+
const tex = await this.resolveImage( filename );
|
|
381
|
+
if ( tex ) result = { texture: tex };
|
|
382
|
+
else this.warn( `image not found for texture "${name}": ${filename}` );
|
|
383
|
+
|
|
384
|
+
} catch ( e ) {
|
|
385
|
+
|
|
386
|
+
this.warn( `failed to load texture "${name}" (${filename}): ${e.message}` );
|
|
387
|
+
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
} else if ( def.class === 'constant' ) {
|
|
393
|
+
|
|
394
|
+
const v = def.params.value;
|
|
395
|
+
if ( v && v.type === 'rgb' ) result = { constant: [ v.value[ 0 ], v.value[ 1 ], v.value[ 2 ] ] };
|
|
396
|
+
else if ( v ) result = { constant: [ v.value[ 0 ], v.value[ 0 ], v.value[ 0 ] ] };
|
|
397
|
+
|
|
398
|
+
} else if ( def.class === 'scale' ) {
|
|
399
|
+
|
|
400
|
+
// Scale = inner_texture * scale_factor. Resolve the inner (recursively if
|
|
401
|
+
// it's a named ref) and the scale factor (rgb/float/spectrum), then propagate
|
|
402
|
+
// both: the inner texture passes through as the `map`, and the scale becomes
|
|
403
|
+
// the material's color tint (three.js multiplies map.rgb × color.rgb).
|
|
404
|
+
result = await this._resolveScaleTexture( name, def );
|
|
405
|
+
|
|
406
|
+
} else {
|
|
407
|
+
|
|
408
|
+
this.warn( `texture class "${def.class}" not supported (texture "${name}")` );
|
|
409
|
+
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
this._textureCache.set( name, result );
|
|
413
|
+
return result;
|
|
414
|
+
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async _resolveScaleTexture( name, def ) {
|
|
418
|
+
|
|
419
|
+
// pbrt-v4 uses "tex" (the inner texture or constant) and "scale" (the multiplier).
|
|
420
|
+
const innerP = def.params.tex;
|
|
421
|
+
let inner = null;
|
|
422
|
+
|
|
423
|
+
if ( innerP?.type === 'texture' && typeof innerP.value[ 0 ] === 'string' ) {
|
|
424
|
+
|
|
425
|
+
inner = await this._resolveNamedTexture( innerP.value[ 0 ] );
|
|
426
|
+
|
|
427
|
+
} else if ( innerP?.type === 'rgb' || innerP?.type === 'color' ) {
|
|
428
|
+
|
|
429
|
+
inner = { constant: [ innerP.value[ 0 ], innerP.value[ 1 ], innerP.value[ 2 ] ] };
|
|
430
|
+
|
|
431
|
+
} else if ( innerP?.type === 'float' ) {
|
|
432
|
+
|
|
433
|
+
const v = innerP.value[ 0 ];
|
|
434
|
+
inner = { constant: [ v, v, v ] };
|
|
435
|
+
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const sP = def.params.scale;
|
|
439
|
+
let scale = [ 1, 1, 1 ];
|
|
440
|
+
if ( sP?.type === 'rgb' || sP?.type === 'color' ) scale = [ sP.value[ 0 ], sP.value[ 1 ], sP.value[ 2 ] ];
|
|
441
|
+
else if ( sP?.type === 'float' ) {
|
|
442
|
+
|
|
443
|
+
const v = sP.value[ 0 ]; scale = [ v, v, v ];
|
|
444
|
+
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if ( inner?.texture ) {
|
|
448
|
+
|
|
449
|
+
const c = inner.constant || [ 1, 1, 1 ];
|
|
450
|
+
return {
|
|
451
|
+
texture: inner.texture,
|
|
452
|
+
constant: [ c[ 0 ] * scale[ 0 ], c[ 1 ] * scale[ 1 ], c[ 2 ] * scale[ 2 ] ]
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const c = inner?.constant || [ 1, 1, 1 ];
|
|
458
|
+
return { constant: [ c[ 0 ] * scale[ 0 ], c[ 1 ] * scale[ 1 ], c[ 2 ] * scale[ 2 ] ] };
|
|
459
|
+
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ── camera ─────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
_buildCamera( cam, film ) {
|
|
465
|
+
|
|
466
|
+
const m = new Matrix4().fromArray( cam.cameraToWorld );
|
|
467
|
+
const e = m.elements;
|
|
468
|
+
|
|
469
|
+
// Columns of cameraToWorld: right(0), up(1), dir(2), eye(3).
|
|
470
|
+
let eye = new Vector3( e[ 12 ], e[ 13 ], e[ 14 ] );
|
|
471
|
+
let dir = new Vector3( e[ 8 ], e[ 9 ], e[ 10 ] );
|
|
472
|
+
let up = new Vector3( e[ 4 ], e[ 5 ], e[ 6 ] );
|
|
473
|
+
|
|
474
|
+
if ( this.convertHandedness ) {
|
|
475
|
+
|
|
476
|
+
eye.z *= - 1; dir.z *= - 1; up.z *= - 1;
|
|
477
|
+
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const target = eye.clone().add( dir );
|
|
481
|
+
|
|
482
|
+
const aspect = film && film.yresolution ? film.xresolution / film.yresolution : 16 / 9;
|
|
483
|
+
const fov = this._verticalFov( cam.params, aspect );
|
|
484
|
+
|
|
485
|
+
const camera = new PerspectiveCamera( fov, aspect, 0.01, 10000 );
|
|
486
|
+
camera.name = 'PBRT Camera';
|
|
487
|
+
camera.up.copy( up.normalize() );
|
|
488
|
+
camera.position.copy( eye );
|
|
489
|
+
camera.lookAt( target );
|
|
490
|
+
camera.updateMatrixWorld( true );
|
|
491
|
+
return camera;
|
|
492
|
+
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// pbrt `fov` is the angle along the SHORTER image axis. THREE uses vertical fov.
|
|
496
|
+
_verticalFov( params, aspect ) {
|
|
497
|
+
|
|
498
|
+
const pbrtFov = pFloat( params, 'fov', 90 );
|
|
499
|
+
if ( aspect >= 1 ) return pbrtFov; // landscape: shorter axis is vertical
|
|
500
|
+
// portrait: pbrt fov is horizontal → convert to vertical
|
|
501
|
+
const h = pbrtFov * Math.PI / 180;
|
|
502
|
+
const v = 2 * Math.atan( Math.tan( h / 2 ) / aspect );
|
|
503
|
+
return v * 180 / Math.PI;
|
|
504
|
+
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ── lights / environment ───────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
async _buildEnvironment( lights ) {
|
|
510
|
+
|
|
511
|
+
const inf = lights.find( l => l.type === 'infinite' );
|
|
512
|
+
if ( ! inf ) return null;
|
|
513
|
+
|
|
514
|
+
const scale = pFloat( inf.params, 'scale', 1 );
|
|
515
|
+
const filename = pString( inf.params, 'filename', null );
|
|
516
|
+
|
|
517
|
+
if ( filename ) {
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
|
|
521
|
+
const tex = await this.resolveEnvironment( filename );
|
|
522
|
+
if ( tex ) {
|
|
523
|
+
|
|
524
|
+
tex.mapping = EquirectangularReflectionMapping;
|
|
525
|
+
return { texture: tex };
|
|
526
|
+
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
this.warn( `infinite-light image not found: ${filename}` );
|
|
530
|
+
|
|
531
|
+
} catch ( e ) {
|
|
532
|
+
|
|
533
|
+
this.warn( `failed to load infinite-light image ${filename}: ${e.message}` );
|
|
534
|
+
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Constant-radiance infinite light → tiny float texture (CDF-buildable).
|
|
540
|
+
const ctx = { resolveNamedTexture: async () => null, warn: ( m ) => this.warn( m ) };
|
|
541
|
+
const L = await resolveSpectrum( inf.params, 'L', ctx, [ 1, 1, 1 ] );
|
|
542
|
+
const rgb = ( L.rgb || [ 1, 1, 1 ] ).map( v => v * scale );
|
|
543
|
+
|
|
544
|
+
const w = 2, h = 1;
|
|
545
|
+
const data = new Float32Array( w * h * 4 );
|
|
546
|
+
for ( let i = 0; i < w * h; i ++ ) {
|
|
547
|
+
|
|
548
|
+
data[ i * 4 + 0 ] = rgb[ 0 ];
|
|
549
|
+
data[ i * 4 + 1 ] = rgb[ 1 ];
|
|
550
|
+
data[ i * 4 + 2 ] = rgb[ 2 ];
|
|
551
|
+
data[ i * 4 + 3 ] = 1;
|
|
552
|
+
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const tex = new DataTexture( data, w, h, RGBAFormat, FloatType );
|
|
556
|
+
tex.mapping = EquirectangularReflectionMapping;
|
|
557
|
+
tex.minFilter = LinearFilter;
|
|
558
|
+
tex.magFilter = LinearFilter;
|
|
559
|
+
tex.needsUpdate = true;
|
|
560
|
+
return { texture: tex };
|
|
561
|
+
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
_reportUnsupportedLights( lights ) {
|
|
565
|
+
|
|
566
|
+
for ( const l of lights ) {
|
|
567
|
+
|
|
568
|
+
if ( l.type !== 'infinite' ) {
|
|
569
|
+
|
|
570
|
+
this.warn( `light "${l.type}" not supported (only infinite lights and emissive area lights are mapped)` );
|
|
571
|
+
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokenizer for the pbrt-v4 scene description grammar.
|
|
3
|
+
*
|
|
4
|
+
* pbrt files are a flat stream of directives. Lexically there are only four
|
|
5
|
+
* things to recognize:
|
|
6
|
+
* - quoted strings: "perspective", "float fov"
|
|
7
|
+
* - numbers: 1, -2.5, 1e-3, .5
|
|
8
|
+
* - brackets: [ ] (array delimiters)
|
|
9
|
+
* - bare words: WorldBegin, Shape, true, false (directives / bools)
|
|
10
|
+
* Comments run from '#' to end of line.
|
|
11
|
+
*
|
|
12
|
+
* Pure JS, no Three.js — keeps it unit-testable in node.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const TokenType = {
|
|
16
|
+
STRING: 'string', // quoted string, quotes stripped
|
|
17
|
+
NUMBER: 'number', // numeric literal, already parsed to Number
|
|
18
|
+
WORD: 'word', // bare identifier (directive name, true/false)
|
|
19
|
+
LBRACKET: '[',
|
|
20
|
+
RBRACKET: ']'
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isWhitespace = c => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f';
|
|
24
|
+
// A number can start with: digit / ".5" / "-1" / "-.5" / "+.5".
|
|
25
|
+
const isDigit = c => c >= '0' && c <= '9';
|
|
26
|
+
const isNumberStart = ( c, next, next2 ) => {
|
|
27
|
+
|
|
28
|
+
if ( isDigit( c ) ) return true;
|
|
29
|
+
if ( c === '.' && isDigit( next ) ) return true;
|
|
30
|
+
if ( c === '-' || c === '+' ) {
|
|
31
|
+
|
|
32
|
+
if ( isDigit( next ) ) return true;
|
|
33
|
+
if ( next === '.' && isDigit( next2 ) ) return true;
|
|
34
|
+
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return false;
|
|
38
|
+
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Tokenize a pbrt source string.
|
|
43
|
+
* @param {string} src
|
|
44
|
+
* @returns {Array<{type: string, value?: string|number}>}
|
|
45
|
+
*/
|
|
46
|
+
export function tokenize( src ) {
|
|
47
|
+
|
|
48
|
+
const tokens = [];
|
|
49
|
+
const n = src.length;
|
|
50
|
+
let i = 0;
|
|
51
|
+
|
|
52
|
+
while ( i < n ) {
|
|
53
|
+
|
|
54
|
+
const c = src[ i ];
|
|
55
|
+
|
|
56
|
+
// Whitespace
|
|
57
|
+
if ( isWhitespace( c ) ) {
|
|
58
|
+
|
|
59
|
+
i ++;
|
|
60
|
+
continue;
|
|
61
|
+
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Comment to end of line
|
|
65
|
+
if ( c === '#' ) {
|
|
66
|
+
|
|
67
|
+
while ( i < n && src[ i ] !== '\n' ) i ++;
|
|
68
|
+
continue;
|
|
69
|
+
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Brackets
|
|
73
|
+
if ( c === '[' ) {
|
|
74
|
+
|
|
75
|
+
tokens.push( { type: TokenType.LBRACKET } );
|
|
76
|
+
i ++;
|
|
77
|
+
continue;
|
|
78
|
+
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if ( c === ']' ) {
|
|
82
|
+
|
|
83
|
+
tokens.push( { type: TokenType.RBRACKET } );
|
|
84
|
+
i ++;
|
|
85
|
+
continue;
|
|
86
|
+
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Quoted string
|
|
90
|
+
if ( c === '"' ) {
|
|
91
|
+
|
|
92
|
+
i ++; // skip opening quote
|
|
93
|
+
let start = i;
|
|
94
|
+
while ( i < n && src[ i ] !== '"' ) i ++;
|
|
95
|
+
if ( i >= n ) throw new Error( 'PBRT tokenizer: unterminated string literal' );
|
|
96
|
+
tokens.push( { type: TokenType.STRING, value: src.slice( start, i ) } );
|
|
97
|
+
i ++; // skip closing quote
|
|
98
|
+
continue;
|
|
99
|
+
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Number
|
|
103
|
+
if ( isNumberStart( c, src[ i + 1 ], src[ i + 2 ] ) ) {
|
|
104
|
+
|
|
105
|
+
let start = i;
|
|
106
|
+
i ++;
|
|
107
|
+
while ( i < n ) {
|
|
108
|
+
|
|
109
|
+
const ch = src[ i ];
|
|
110
|
+
if ( ( ch >= '0' && ch <= '9' ) || ch === '.' || ch === 'e' || ch === 'E' ||
|
|
111
|
+
ch === '-' || ch === '+' ) {
|
|
112
|
+
|
|
113
|
+
i ++;
|
|
114
|
+
|
|
115
|
+
} else break;
|
|
116
|
+
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const text = src.slice( start, i );
|
|
120
|
+
const num = Number( text );
|
|
121
|
+
if ( Number.isNaN( num ) ) throw new Error( `PBRT tokenizer: invalid number "${text}"` );
|
|
122
|
+
tokens.push( { type: TokenType.NUMBER, value: num } );
|
|
123
|
+
continue;
|
|
124
|
+
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Bare word (directive, true/false, etc.)
|
|
128
|
+
{
|
|
129
|
+
|
|
130
|
+
let start = i;
|
|
131
|
+
while ( i < n && ! isWhitespace( src[ i ] ) && src[ i ] !== '"' &&
|
|
132
|
+
src[ i ] !== '[' && src[ i ] !== ']' && src[ i ] !== '#' ) i ++;
|
|
133
|
+
tokens.push( { type: TokenType.WORD, value: src.slice( start, i ) } );
|
|
134
|
+
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return tokens;
|
|
140
|
+
|
|
141
|
+
}
|