Add texture loading patterns and OpenPBRMaterial support

- Add sync/Loaded variants for material conversion functions:
  - convertOpenPBRToMeshPhysicalMaterial (immediate return)
  - convertOpenPBRToMeshPhysicalMaterialLoaded (waits for textures)
  - convertToOpenPBRMaterial (immediate return)
  - convertToOpenPBRMaterialLoaded (waits for textures)

- Add full texture support for OpenPBRMaterial custom shader:
  - map, roughnessMap, metalnessMap, emissiveMap, normalMap, aoMap

- Add HDR/EXR texture format detection and decoding:
  - Magic byte detection in getMimeType()
  - TinyUSDZ HDR decoder (faster) with Three.js fallback
  - Three.js EXR decoder with TinyUSDZ fallback

- Update MaterialX documentation:
  - Three.js/WebGL integration section
  - Supported parameters and texture maps tables
  - MaterialX NodeGraph and node shader documentation
  - Complete USDA examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Syoyo Fujita
2026-01-04 02:08:24 +09:00
parent e4400c53d6
commit 4eb8912457
4 changed files with 1379 additions and 166 deletions

View File

@@ -326,6 +326,440 @@ Based on Blender 4.5 development:
- MaterialX 1.39.0+ includes OpenPBR Surface
- MaterialX 1.39.1 added Standard Surface ↔ OpenPBR translation graphs
## Three.js / WebGL MaterialX Integration
TinyUSDZ provides JavaScript APIs for converting OpenPBR/MaterialX materials to Three.js materials. Two material implementations are available:
### Material Implementations
| Implementation | Class | Use Case |
|---------------|-------|----------|
| **MeshPhysicalMaterial** | `THREE.MeshPhysicalMaterial` | Standard Three.js PBR material, broad compatibility |
| **OpenPBRMaterial** | Custom `ShaderMaterial` | Full OpenPBR BRDF with Oren-Nayar diffuse, coat IOR, fuzz layer |
### MeshPhysicalMaterial Conversion
Converts OpenPBR parameters to Three.js MeshPhysicalMaterial properties.
#### Supported Parameters
| OpenPBR Parameter | MeshPhysicalMaterial Property | Notes |
|------------------|------------------------------|-------|
| `base_color` | `color` | Diffuse/albedo color |
| `base_metalness` | `metalness` | Metallic factor (0-1) |
| `specular_roughness` | `roughness` | Surface roughness (0-1) |
| `specular_ior` | `ior` | Index of refraction |
| `specular_color` | `specularColor` | Specular tint |
| `specular_anisotropy` | `anisotropy` | Anisotropic stretching |
| `transmission_weight` | `transmission` | Transmission factor |
| `transmission_color` | `attenuationColor` | Transmission color |
| `coat_weight` | `clearcoat` | Clearcoat intensity |
| `coat_roughness` | `clearcoatRoughness` | Clearcoat roughness |
| `sheen_weight` / `fuzz_weight` | `sheen` | Sheen intensity |
| `sheen_color` / `fuzz_color` | `sheenColor` | Sheen tint |
| `sheen_roughness` / `fuzz_roughness` | `sheenRoughness` | Sheen roughness |
| `thin_film_weight` | `iridescence` | Iridescence intensity |
| `thin_film_thickness` | `iridescenceThicknessRange` | Film thickness |
| `thin_film_ior` | `iridescenceIOR` | Film IOR |
| `emission_color` | `emissive` | Emission color |
| `emission_luminance` | `emissiveIntensity` | Emission strength |
| `geometry_opacity` / `opacity` | `opacity` | Alpha value |
| `geometry_normal` / `normal` | `normalMap` | Normal map texture |
#### Supported Texture Maps
| OpenPBR Texture | MeshPhysicalMaterial Map | Channel |
|-----------------|-------------------------|---------|
| `base_color` | `map` | RGB |
| `specular_roughness` | `roughnessMap` | G channel |
| `base_metalness` | `metalnessMap` | B channel |
| `emission_color` | `emissiveMap` | RGB |
| `geometry_normal` | `normalMap` | RGB (tangent space) |
| `geometry_opacity` | `alphaMap` | Single channel |
#### API Functions
```javascript
import {
convertOpenPBRToMeshPhysicalMaterial,
convertOpenPBRToMeshPhysicalMaterialLoaded
} from 'tinyusdz/TinyUSDZMaterialX.js';
// Returns immediately, textures load in background (fire-and-forget)
const material = convertOpenPBRToMeshPhysicalMaterial(materialData, usdScene, options);
// Waits for all textures to load before returning
const material = await convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene, options);
```
### OpenPBRMaterial (Custom Shader)
Full OpenPBR BRDF implementation as a Three.js ShaderMaterial, supporting features not available in MeshPhysicalMaterial.
#### Additional Features vs MeshPhysicalMaterial
| Feature | OpenPBRMaterial | MeshPhysicalMaterial |
|---------|----------------|---------------------|
| Oren-Nayar Diffuse | ✅ `base_diffuse_roughness` | ❌ Lambertian only |
| Coat Color | ✅ `coat_color` | ❌ White only |
| Coat IOR | ✅ `coat_ior` | ❌ Fixed 1.5 |
| Fuzz Layer | ✅ OpenPBR formulation | ⚠️ Approximated as sheen |
| Thin Film Physics | ✅ Interference simulation | ⚠️ Simplified |
#### Supported Parameters
| OpenPBR Parameter | Uniform Name | Default |
|------------------|--------------|---------|
| **Base Layer** | | |
| `base_weight` | `base_weight` | 1.0 |
| `base_color` | `base_color` | (0.8, 0.8, 0.8) |
| `base_metalness` | `base_metalness` | 0.0 |
| `base_diffuse_roughness` | `base_diffuse_roughness` | 0.0 |
| **Specular Layer** | | |
| `specular_weight` | `specular_weight` | 1.0 |
| `specular_color` | `specular_color` | (1.0, 1.0, 1.0) |
| `specular_roughness` | `specular_roughness` | 0.3 |
| `specular_ior` | `specular_ior` | 1.5 |
| `specular_anisotropy` | `specular_anisotropy` | 0.0 |
| `specular_rotation` | `specular_rotation` | 0.0 |
| **Coat Layer** | | |
| `coat_weight` | `coat_weight` | 0.0 |
| `coat_color` | `coat_color` | (1.0, 1.0, 1.0) |
| `coat_roughness` | `coat_roughness` | 0.0 |
| `coat_ior` | `coat_ior` | 1.5 |
| **Fuzz Layer** | | |
| `fuzz_weight` | `fuzz_weight` | 0.0 |
| `fuzz_color` | `fuzz_color` | (1.0, 1.0, 1.0) |
| `fuzz_roughness` | `fuzz_roughness` | 0.5 |
| **Thin Film** | | |
| `thin_film_weight` | `thin_film_weight` | 0.0 |
| `thin_film_thickness` | `thin_film_thickness` | 500.0 nm |
| `thin_film_ior` | `thin_film_ior` | 1.5 |
| **Transmission** | | |
| `transmission_weight` | `transmission_weight` | 0.0 |
| `transmission_color` | `transmission_color` | (1.0, 1.0, 1.0) |
| **Emission** | | |
| `emission_luminance` | `emission_luminance` | 0.0 |
| `emission_color` | `emission_color` | (1.0, 1.0, 1.0) |
| **Geometry** | | |
| `geometry_opacity` | `geometry_opacity` | 1.0 |
#### Supported Texture Maps
| OpenPBR Texture | Property | Shader Define |
|-----------------|----------|---------------|
| `base_color` | `map` | `USE_MAP` |
| `specular_roughness` | `roughnessMap` | `USE_ROUGHNESSMAP` |
| `base_metalness` | `metalnessMap` | `USE_METALNESSMAP` |
| `emission_color` | `emissiveMap` | `USE_EMISSIVEMAP` |
| `geometry_normal` | `normalMap` | `USE_NORMALMAP` |
| `ambient_occlusion` | `aoMap` | `USE_AOMAP` |
#### API Functions
```javascript
// In materialx.js demo
// Returns immediately, textures load in background
const material = convertToOpenPBRMaterial(matData, nativeLoader);
// Waits for all textures to load before returning
const material = await convertToOpenPBRMaterialLoaded(matData, nativeLoader);
```
### Texture Loading Patterns
Two loading patterns are available for both material types:
| Pattern | Function Suffix | Behavior | Use Case |
|---------|----------------|----------|----------|
| **Immediate** | (none) | Returns material immediately, textures load asynchronously | Interactive loading, progressive display |
| **Loaded** | `Loaded` | Awaits all textures before returning | Batch rendering, screenshots |
#### Example: Immediate Pattern
```javascript
// Material appears immediately with base colors
// Textures pop in as they load
const material = convertOpenPBRToMeshPhysicalMaterial(data, scene);
mesh.material = material;
// Render loop continues, textures appear when ready
```
#### Example: Loaded Pattern
```javascript
// Wait for complete material with all textures
const material = await convertOpenPBRToMeshPhysicalMaterialLoaded(data, scene);
mesh.material = material;
// All textures are ready before first render
```
### HDR/EXR Texture Support
Both material converters support HDR and EXR texture formats:
| Format | Decoder | Fallback |
|--------|---------|----------|
| HDR (Radiance) | TinyUSDZ WASM (faster) | Three.js HDRLoader |
| EXR (OpenEXR) | Three.js EXRLoader | TinyUSDZ (for unsupported compression) |
```javascript
// Initialize TinyUSDZ module reference for HDR/EXR fallback
import { TinyUSDZLoaderUtils } from 'tinyusdz/TinyUSDZLoaderUtils.js';
import { setTinyUSDZ } from 'tinyusdz/TinyUSDZMaterialX.js';
// After TinyUSDZ WASM initialization
TinyUSDZLoaderUtils.setTinyUSDZ(tinyusdzModule);
setTinyUSDZ(tinyusdzModule);
```
### Usage Example
```javascript
import { TinyUSDZLoader } from 'tinyusdz/TinyUSDZLoader.js';
import { TinyUSDZLoaderUtils } from 'tinyusdz/TinyUSDZLoaderUtils.js';
import { OpenPBRMaterial } from './OpenPBRMaterial.js';
// Initialize loader
const loader = new TinyUSDZLoader();
await loader.init();
// Set TinyUSDZ reference for HDR/EXR support
TinyUSDZLoaderUtils.setTinyUSDZ(loader.native_);
// Load USD file
const usd = await loader.loadAsync('model.usdz');
// Convert materials using TinyUSDZLoaderUtils
const material = await TinyUSDZLoaderUtils.convertMaterial(
materialData,
usd,
{
preferredMaterialType: 'physical', // or 'openpbr'
envMap: envTexture,
envMapIntensity: 1.0
}
);
```
## MaterialX NodeGraph and Node Shaders
TinyUSDZ supports MaterialX node graphs as used in USD shader networks. This section documents the supported node types and how they map to USD primitives.
### MaterialX Node Definition IDs
When MaterialX is exported from applications like Blender, shader nodes use specific `info:id` values:
| Node Definition ID | Description | Used By |
|-------------------|-------------|---------|
| `ND_open_pbr_surface_surfaceshader` | OpenPBR Surface shader | Blender 4.5+ |
| `ND_standard_surface_surfaceshader` | Autodesk Standard Surface | Maya, older Blender |
| `ND_UsdPreviewSurface_surfaceshader` | USD Preview Surface | Universal fallback |
| `ND_image_color3` | Color image texture | Texture nodes |
| `ND_image_float` | Grayscale image texture | Roughness, metalness |
| `ND_texcoord_vector2` | UV coordinate generator | Texture coordinates |
| `ND_normalmap` | Normal map processor | Normal mapping |
### USD NodeGraph Structure
MaterialX node graphs in USD appear as `NodeGraph` prims containing shader nodes:
```usda
def NodeGraph "NG_materialx" {
# Texture coordinate node
def Shader "texcoord" {
uniform token info:id = "ND_texcoord_vector2"
int inputs:index = 0
float2 outputs:out
}
# Image texture node
def Shader "base_color_image" {
uniform token info:id = "ND_image_color3"
asset inputs:file = @textures/diffuse.png@
string inputs:filtertype = "linear"
float2 inputs:texcoord.connect = </Material/NG_materialx/texcoord.outputs:out>
color3f outputs:out
}
# Output interface
color3f outputs:base_color.connect = </Material/NG_materialx/base_color_image.outputs:out>
}
```
### Supported MaterialX Nodes (Three.js TSL) [W.I.P.]
> ⚠️ **Work in Progress**: Three.js TSL node graph processing is experimental and under active development. Not all nodes are fully tested.
The following MaterialX nodes are supported in the Three.js TSL (Three Shading Language) implementation:
#### Math Operations
| Node Type | MaterialX Name | Description | Inputs |
|-----------|---------------|-------------|--------|
| `add` | `ND_add_*` | Add two values | `in1`, `in2` |
| `subtract` | `ND_subtract_*` | Subtract values | `in1`, `in2` |
| `multiply` | `ND_multiply_*` | Multiply values | `in1`, `in2` |
| `divide` | `ND_divide_*` | Divide values | `in1`, `in2` |
| `power` | `ND_power_*` | Power function | `in1`, `in2` |
| `clamp` | `ND_clamp_*` | Clamp to range | `in`, `low`, `high` |
| `mix` | `ND_mix_*` | Linear interpolation | `bg`, `fg`, `mix` |
| `remap` | `ND_remap_*` | Remap value range | `in`, `inlow`, `inhigh`, `outlow`, `outhigh` |
| `smoothstep` | `ND_smoothstep_*` | Smooth interpolation | `low`, `high`, `in` |
#### Vector Operations
| Node Type | MaterialX Name | Description | Inputs |
|-----------|---------------|-------------|--------|
| `normalize` | `ND_normalize_*` | Normalize vector | `in` |
| `dotproduct` | `ND_dotproduct_*` | Dot product | `in1`, `in2` |
| `extract` | `ND_extract_*` | Extract component | `in`, `index` |
| `combine2` | `ND_combine2_*` | Combine to vec2 | `in1`, `in2` |
| `combine3` | `ND_combine3_*` | Combine to vec3 | `in1`, `in2`, `in3` |
| `combine4` | `ND_combine4_*` | Combine to vec4 | `in1`, `in2`, `in3`, `in4` |
#### Texture & Geometry
| Node Type | MaterialX Name | Description | Inputs |
|-----------|---------------|-------------|--------|
| `image` | `ND_image_*` | Sample texture | `file`, `texcoord` |
| `tiledimage` | `ND_tiledimage_*` | Tiled texture sample | `file`, `texcoord`, `uvtiling` |
| `texcoord` | `ND_texcoord_vector2` | UV coordinates | `index` |
| `position` | `ND_position_*` | World position | - |
| `normal` | `ND_normal_*` | World normal | - |
| `tangent` | `ND_tangent_*` | World tangent | - |
#### Color Operations
| Node Type | MaterialX Name | Description | Inputs |
|-----------|---------------|-------------|--------|
| `luminance` | `ND_luminance_*` | RGB to luminance | `in` |
| `constant` | `ND_constant_*` | Constant value | `value` |
#### Conditional
| Node Type | MaterialX Name | Description | Inputs |
|-----------|---------------|-------------|--------|
| `ifgreater` | `ND_ifgreater_*` | Conditional select | `value1`, `value2`, `in1`, `in2` |
### Node Connection Syntax
In USD, MaterialX node connections use the standard connection syntax:
```usda
# Connect texture coordinate to image node
float2 inputs:texcoord.connect = </Material/NodeGraph/texcoord.outputs:out>
# Connect image output to shader input
color3f inputs:base_color.connect = </Material/NodeGraph/diffuse_image.outputs:out>
```
### Primary UV Set Configuration
TinyUSDZ supports configuring the primary UV set name (similar to OpenUSD's `USDMTLX_PRIMARY_UV_NAME`):
```cpp
// C++ configuration
tinyusdz::MtlxConfig config;
config.primary_uv_name = "st"; // Default UV set name
config.secondary_uv_name_prefix = "st"; // Pattern for st1, st2, etc.
```
### Example: Complete MaterialX Material in USD
```usda
def Material "OpenPBRMaterial" {
token outputs:surface.connect = </OpenPBRMaterial/OpenPBRShader.outputs:surface>
def Shader "OpenPBRShader" {
uniform token info:id = "ND_open_pbr_surface_surfaceshader"
# Base layer with texture
color3f inputs:base_color.connect = </OpenPBRMaterial/NodeGraph.outputs:base_color>
float inputs:base_metalness = 0.0
# Specular layer
float inputs:specular_roughness.connect = </OpenPBRMaterial/NodeGraph.outputs:roughness>
float inputs:specular_ior = 1.5
# Normal map
normal3f inputs:geometry_normal.connect = </OpenPBRMaterial/NodeGraph.outputs:normal>
token outputs:surface
}
def NodeGraph "NodeGraph" {
# UV coordinates
def Shader "texcoord" {
uniform token info:id = "ND_texcoord_vector2"
int inputs:index = 0
float2 outputs:out
}
# Base color texture
def Shader "diffuse_tex" {
uniform token info:id = "ND_image_color3"
asset inputs:file = @textures/diffuse.png@
string inputs:filtertype = "linear"
float2 inputs:texcoord.connect = </OpenPBRMaterial/NodeGraph/texcoord.outputs:out>
color3f outputs:out
}
# Roughness texture
def Shader "roughness_tex" {
uniform token info:id = "ND_image_float"
asset inputs:file = @textures/roughness.png@
float2 inputs:texcoord.connect = </OpenPBRMaterial/NodeGraph/texcoord.outputs:out>
float outputs:out
}
# Normal map
def Shader "normal_tex" {
uniform token info:id = "ND_normalmap"
asset inputs:file = @textures/normal.png@
float2 inputs:texcoord.connect = </OpenPBRMaterial/NodeGraph/texcoord.outputs:out>
normal3f outputs:out
}
# NodeGraph outputs
color3f outputs:base_color.connect = </OpenPBRMaterial/NodeGraph/diffuse_tex.outputs:out>
float outputs:roughness.connect = </OpenPBRMaterial/NodeGraph/roughness_tex.outputs:out>
normal3f outputs:normal.connect = </OpenPBRMaterial/NodeGraph/normal_tex.outputs:out>
}
}
```
### Supported Surface Shaders
TinyUSDZ supports the following MaterialX surface shader types:
| Shader Type | C++ Struct | info:id Value |
|-------------|-----------|---------------|
| OpenPBR Surface | `MtlxOpenPBRSurface` | `ND_open_pbr_surface_surfaceshader` |
| Standard Surface | `MtlxAutodeskStandardSurface` | `ND_standard_surface_surfaceshader` |
| USD Preview Surface | `MtlxUsdPreviewSurface` | `ND_UsdPreviewSurface_surfaceshader` |
### Light Shader Nodes (EDF)
MaterialX light shaders (Emission Distribution Functions) are also supported:
| Node Type | Description | Key Inputs |
|-----------|-------------|------------|
| `uniform_edf` | Uniform light emission | `color` |
| `conical_edf` | Conical/spot light emission | `color`, `inner_angle`, `outer_angle` |
| `measured_edf` | IES profile emission | `color`, `file` |
| `light` | Light shader wrapper | `edf`, `intensity` |
### Implementation Status
| Feature | C++ Core | JavaScript/Three.js |
|---------|----------|---------------------|
| NodeGraph parsing | ✅ Full | ✅ Basic |
| Surface shader conversion | ✅ OpenPBR, Standard, Preview | ✅ OpenPBR |
| Math nodes | ⚠️ Partial | ✅ Full |
| Texture nodes | ✅ image, tiledimage | ✅ image, tiledimage |
| Geometry nodes | ✅ texcoord, normal | ✅ texcoord, position, normal |
| Light nodes (EDF) | ✅ Full | ❌ Not implemented |
## Implementation Details
### Color Space Matrices

View File

@@ -8,6 +8,7 @@ import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js';
import { TinyUSDZLoader } from 'tinyusdz/TinyUSDZLoader.js';
import { TinyUSDZLoaderUtils } from 'tinyusdz/TinyUSDZLoaderUtils.js';
import { setTinyUSDZ as setMaterialXTinyUSDZ } from 'tinyusdz/TinyUSDZMaterialX.js';
import { OpenPBRMaterial } from './OpenPBRMaterial.js';
import { OpenPBRValidator, OpenPBRGroundTruth } from './OpenPBRValidation.js';
@@ -526,6 +527,12 @@ async function initLoader() {
updateStatus('Initializing TinyUSDZ WASM...');
loaderState.loader = new TinyUSDZLoader(null, { maxMemoryLimitMB: 512 });
await loaderState.loader.init({ useMemory64: false });
// Set TinyUSDZ WASM module reference for HDR/EXR texture decoding fallback
const tinyusdzModule = loaderState.loader.native_;
TinyUSDZLoaderUtils.setTinyUSDZ(tinyusdzModule);
setMaterialXTinyUSDZ(tinyusdzModule);
updateStatus('TinyUSDZ initialized');
}
@@ -1773,8 +1780,8 @@ async function convertMaterial(matData, index) {
let material;
if (useOpenPBRMaterial) {
// Create OpenPBRMaterial directly (async for texture loading)
material = await convertToOpenPBRMaterial(matData, loaderState.nativeLoader);
// Create OpenPBRMaterial directly (Loaded version waits for textures)
material = await convertToOpenPBRMaterialLoaded(matData, loaderState.nativeLoader);
material.envMap = threeState.envMap;
material.envMapIntensity = settings.envMapIntensity;
} else {
@@ -1891,132 +1898,298 @@ function extractTextureId(param) {
return -1;
}
// ============================================================================
// OpenPBRMaterial Conversion Helpers
// ============================================================================
/**
* Extract value from OpenPBR parameter
*/
function extractOpenPBRValue(param, defaultVal) {
if (param === undefined || param === null) return defaultVal;
if (typeof param === 'object' && param.value !== undefined) return param.value;
if (typeof param === 'number') return param;
return defaultVal;
}
/**
* Extract color from OpenPBR parameter
*/
function extractOpenPBRColor(param, defaultVal) {
if (!param) return new THREE.Color(...defaultVal);
if (param.r !== undefined) return new THREE.Color(param.r, param.g, param.b);
if (Array.isArray(param)) return new THREE.Color(...param);
if (typeof param === 'object' && param.value) {
if (Array.isArray(param.value)) return new THREE.Color(...param.value);
if (param.value.r !== undefined) return new THREE.Color(param.value.r, param.value.g, param.value.b);
}
return new THREE.Color(...defaultVal);
}
/**
* Check if OpenPBR parameter has a texture
*/
function hasOpenPBRTexture(param) {
return param && typeof param === 'object' && param.textureId !== undefined && param.textureId >= 0;
}
/**
* Get texture ID from OpenPBR parameter
*/
function getOpenPBRTextureId(param) {
if (!param || typeof param !== 'object') return -1;
return param.textureId !== undefined ? param.textureId : -1;
}
/**
* Resolve actual texture ID (handles duplicate images in USDZ)
*/
function resolveTextureId(nativeLoader, textureId) {
if (textureId < 0) return textureId;
try {
const tex = nativeLoader.getTexture(textureId);
const texImage = nativeLoader.getImage(tex.textureImageId);
if (texImage.bufferId === -1 && texImage.uri) {
const filename = texImage.uri.replace(/^\.\//, '');
const numImages = nativeLoader.numImages();
for (let i = 0; i < numImages; i++) {
const altImage = nativeLoader.getImage(i);
if (altImage.bufferId >= 0 && altImage.uri === filename) {
const numTextures = nativeLoader.numTextures();
for (let t = 0; t < numTextures; t++) {
const altTex = nativeLoader.getTexture(t);
if (altTex.textureImageId === i) {
return t;
}
}
break;
}
}
}
} catch (e) {
// Ignore errors, return original ID
}
return textureId;
}
/**
* Create base OpenPBRMaterial with scalar/color values (no textures)
*/
function createBaseOpenPBRMaterial(openPBR) {
const material = new OpenPBRMaterial({
// Base layer
base_weight: extractOpenPBRValue(openPBR.base_weight, 1.0),
base_color: extractOpenPBRColor(openPBR.base_color, [0.8, 0.8, 0.8]),
base_metalness: extractOpenPBRValue(openPBR.base_metalness, 0.0),
base_diffuse_roughness: extractOpenPBRValue(openPBR.base_diffuse_roughness, 0.0),
// Specular layer
specular_weight: extractOpenPBRValue(openPBR.specular_weight, 1.0),
specular_color: extractOpenPBRColor(openPBR.specular_color, [1.0, 1.0, 1.0]),
specular_roughness: extractOpenPBRValue(openPBR.specular_roughness, 0.3),
specular_ior: extractOpenPBRValue(openPBR.specular_ior, 1.5),
specular_anisotropy: extractOpenPBRValue(openPBR.specular_anisotropy, 0.0),
// Coat layer
coat_weight: extractOpenPBRValue(openPBR.coat_weight, 0.0),
coat_color: extractOpenPBRColor(openPBR.coat_color, [1.0, 1.0, 1.0]),
coat_roughness: extractOpenPBRValue(openPBR.coat_roughness, 0.0),
coat_ior: extractOpenPBRValue(openPBR.coat_ior, 1.5),
// Fuzz layer
fuzz_weight: extractOpenPBRValue(openPBR.fuzz_weight || openPBR.sheen_weight, 0.0),
fuzz_color: extractOpenPBRColor(openPBR.fuzz_color || openPBR.sheen_color, [1.0, 1.0, 1.0]),
fuzz_roughness: extractOpenPBRValue(openPBR.fuzz_roughness || openPBR.sheen_roughness, 0.5),
// Thin film
thin_film_weight: extractOpenPBRValue(openPBR.thin_film_weight, 0.0),
thin_film_thickness: extractOpenPBRValue(openPBR.thin_film_thickness, 500.0),
thin_film_ior: extractOpenPBRValue(openPBR.thin_film_ior, 1.5),
// Emission
emission_luminance: extractOpenPBRValue(openPBR.emission_luminance, 0.0),
emission_color: extractOpenPBRColor(openPBR.emission_color, [1.0, 1.0, 1.0]),
// Geometry
geometry_opacity: extractOpenPBRValue(openPBR.geometry_opacity || openPBR.opacity, 1.0)
});
// Store texture references for later management
material.userData.textures = {};
material.userData.materialType = 'OpenPBRMaterial';
return material;
}
/**
* Convert material data to OpenPBRMaterial
* Waits for all textures to load before returning.
* @param {Object} matData - Material data from USD
* @param {Object} nativeLoader - TinyUSDZ native loader for texture access
* @returns {Promise<OpenPBRMaterial>} The created material
*/
async function convertToOpenPBRMaterial(matData, nativeLoader = null) {
async function convertToOpenPBRMaterialLoaded(matData, nativeLoader = null) {
const openPBR = matData.openPBR || matData.openPBRShader || matData;
// Extract values with fallbacks
const extractValue = (param, defaultVal) => {
if (param === undefined || param === null) return defaultVal;
if (typeof param === 'object' && param.value !== undefined) return param.value;
if (typeof param === 'number') return param;
return defaultVal;
};
const extractColor = (param, defaultVal) => {
if (!param) return new THREE.Color(...defaultVal);
if (param.r !== undefined) return new THREE.Color(param.r, param.g, param.b);
if (Array.isArray(param)) return new THREE.Color(...param);
if (typeof param === 'object' && param.value) {
if (Array.isArray(param.value)) return new THREE.Color(...param.value);
if (param.value.r !== undefined) return new THREE.Color(param.value.r, param.value.g, param.value.b);
}
return new THREE.Color(...defaultVal);
};
const material = new OpenPBRMaterial({
// Base layer
base_weight: extractValue(openPBR.base_weight, 1.0),
base_color: extractColor(openPBR.base_color, [0.8, 0.8, 0.8]),
base_metalness: extractValue(openPBR.base_metalness, 0.0),
base_diffuse_roughness: extractValue(openPBR.base_diffuse_roughness, 0.0),
// Specular layer
specular_weight: extractValue(openPBR.specular_weight, 1.0),
specular_color: extractColor(openPBR.specular_color, [1.0, 1.0, 1.0]),
specular_roughness: extractValue(openPBR.specular_roughness, 0.3),
specular_ior: extractValue(openPBR.specular_ior, 1.5),
specular_anisotropy: extractValue(openPBR.specular_anisotropy, 0.0),
// Coat layer
coat_weight: extractValue(openPBR.coat_weight, 0.0),
coat_color: extractColor(openPBR.coat_color, [1.0, 1.0, 1.0]),
coat_roughness: extractValue(openPBR.coat_roughness, 0.0),
coat_ior: extractValue(openPBR.coat_ior, 1.5),
// Fuzz layer
fuzz_weight: extractValue(openPBR.fuzz_weight || openPBR.sheen_weight, 0.0),
fuzz_color: extractColor(openPBR.fuzz_color || openPBR.sheen_color, [1.0, 1.0, 1.0]),
fuzz_roughness: extractValue(openPBR.fuzz_roughness || openPBR.sheen_roughness, 0.5),
// Thin film
thin_film_weight: extractValue(openPBR.thin_film_weight, 0.0),
thin_film_thickness: extractValue(openPBR.thin_film_thickness, 500.0),
thin_film_ior: extractValue(openPBR.thin_film_ior, 1.5),
// Emission
emission_luminance: extractValue(openPBR.emission_luminance, 0.0),
emission_color: extractColor(openPBR.emission_color, [1.0, 1.0, 1.0]),
// Geometry
geometry_opacity: extractValue(openPBR.geometry_opacity || openPBR.opacity, 1.0)
});
// Set color alias for compatibility
material.uniforms.base_color.value.copy(material.uniforms.base_color.value);
// Load normal map texture if available
// The normal map is in openPBR.geometry.normal (not openPBR.normal)
const material = createBaseOpenPBRMaterial(openPBR);
const geometrySection = openPBR.geometry || {};
const normalParam = geometrySection.normal || openPBR.normal || openPBR.geometry_normal;
const normalTextureId = extractTextureId(normalParam);
if (normalTextureId >= 0 && nativeLoader) {
if (!nativeLoader) return material;
// Load base color texture (map)
if (hasOpenPBRTexture(openPBR.base_color)) {
try {
// nativeLoader itself has getTexture, getImage methods - use it directly as usdScene
// First, try to find an image with valid bufferId for the same filename
// This works around the issue where TinyUSDZ creates duplicate images with different paths
let actualTextureId = normalTextureId;
const tex = nativeLoader.getTexture(normalTextureId);
const texImage = nativeLoader.getImage(tex.textureImageId);
if (texImage.bufferId === -1 && texImage.uri) {
// Try to find an alternative image with the same filename but valid bufferId
const filename = texImage.uri.replace(/^\.\//, ''); // Remove leading ./
const numImages = nativeLoader.numImages();
for (let i = 0; i < numImages; i++) {
const altImage = nativeLoader.getImage(i);
if (altImage.bufferId >= 0 && altImage.uri === filename) {
// Find a texture that uses this image
const numTextures = nativeLoader.numTextures();
for (let t = 0; t < numTextures; t++) {
const altTex = nativeLoader.getTexture(t);
if (altTex.textureImageId === i) {
actualTextureId = t;
break;
}
}
break;
}
}
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(openPBR.base_color));
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId);
if (texture) {
material.map = texture;
material.userData.textures.map = { textureId: texId, texture };
}
} catch (err) {
console.warn('Failed to load base color texture:', err);
}
}
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, actualTextureId);
// Load roughness texture
if (hasOpenPBRTexture(openPBR.specular_roughness)) {
try {
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(openPBR.specular_roughness));
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId);
if (texture) {
material.roughnessMap = texture;
material.userData.textures.roughnessMap = { textureId: texId, texture };
}
} catch (err) {
console.warn('Failed to load roughness texture:', err);
}
}
// Load metalness texture
if (hasOpenPBRTexture(openPBR.base_metalness)) {
try {
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(openPBR.base_metalness));
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId);
if (texture) {
material.metalnessMap = texture;
material.userData.textures.metalnessMap = { textureId: texId, texture };
}
} catch (err) {
console.warn('Failed to load metalness texture:', err);
}
}
// Load emissive texture
if (hasOpenPBRTexture(openPBR.emission_color)) {
try {
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(openPBR.emission_color));
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId);
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId: texId, texture };
}
} catch (err) {
console.warn('Failed to load emissive texture:', err);
}
}
// Load normal map
const normalParam = geometrySection.normal || openPBR.normal || openPBR.geometry_normal;
if (hasOpenPBRTexture(normalParam)) {
try {
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(normalParam));
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId);
if (texture) {
material.normalMap = texture;
// Apply normal map scale if available
const normalMapScale = extractValue(openPBR.normal_map_scale, 1.0);
material.userData.textures.normalMap = { textureId: texId, texture };
const normalMapScale = extractOpenPBRValue(geometrySection.normal_map_scale || openPBR.normal_map_scale, 1.0);
if (material.uniforms.normalScale) {
material.uniforms.normalScale.value.set(normalMapScale, normalMapScale);
}
// Normal map loaded successfully
}
} catch (err) {
console.warn('Failed to load normal map texture:', err);
}
}
// Store geometry_normal info in userData for UI display
// Load AO map (if available in geometry section)
const aoParam = geometrySection.ambient_occlusion || openPBR.ambient_occlusion;
if (hasOpenPBRTexture(aoParam)) {
try {
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(aoParam));
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId);
if (texture) {
material.aoMap = texture;
material.userData.textures.aoMap = { textureId: texId, texture };
}
} catch (err) {
console.warn('Failed to load AO texture:', err);
}
}
// Store geometry info in userData
material.userData.geometry_normal = {
hasTexture: normalTextureId >= 0,
textureId: normalTextureId,
scale: extractValue(openPBR.normal_map_scale, 1.0)
hasTexture: hasOpenPBRTexture(normalParam),
textureId: getOpenPBRTextureId(normalParam),
scale: extractOpenPBRValue(geometrySection.normal_map_scale || openPBR.normal_map_scale, 1.0)
};
return material;
}
/**
* Convert material data to OpenPBRMaterial (legacy pattern)
* Returns material immediately, textures load asynchronously in background.
* @param {Object} matData - Material data from USD
* @param {Object} nativeLoader - TinyUSDZ native loader for texture access
* @returns {OpenPBRMaterial} The created material (textures load asynchronously)
*/
function convertToOpenPBRMaterial(matData, nativeLoader = null) {
const openPBR = matData.openPBR || matData.openPBRShader || matData;
const material = createBaseOpenPBRMaterial(openPBR);
const geometrySection = openPBR.geometry || {};
if (!nativeLoader) return material;
// Helper for fire-and-forget texture loading
const loadTextureAsync = (param, mapName, onLoad = null) => {
if (!hasOpenPBRTexture(param)) return;
const texId = resolveTextureId(nativeLoader, getOpenPBRTextureId(param));
TinyUSDZLoaderUtils.getTextureFromUSD(nativeLoader, texId).then((texture) => {
if (texture) {
material[mapName] = texture;
material.userData.textures[mapName] = { textureId: texId, texture };
material.needsUpdate = true;
if (onLoad) onLoad(texture);
}
}).catch((err) => {
console.warn(`Failed to load ${mapName} texture:`, err);
});
};
// Queue texture loading (fire-and-forget)
loadTextureAsync(openPBR.base_color, 'map');
loadTextureAsync(openPBR.specular_roughness, 'roughnessMap');
loadTextureAsync(openPBR.base_metalness, 'metalnessMap');
loadTextureAsync(openPBR.emission_color, 'emissiveMap');
// Normal map with scale
const normalParam = geometrySection.normal || openPBR.normal || openPBR.geometry_normal;
loadTextureAsync(normalParam, 'normalMap', () => {
const normalMapScale = extractOpenPBRValue(geometrySection.normal_map_scale || openPBR.normal_map_scale, 1.0);
if (material.uniforms.normalScale) {
material.uniforms.normalScale.value.set(normalMapScale, normalMapScale);
}
});
// AO map
const aoParam = geometrySection.ambient_occlusion || openPBR.ambient_occlusion;
loadTextureAsync(aoParam, 'aoMap');
// Store geometry info in userData
material.userData.geometry_normal = {
hasTexture: hasOpenPBRTexture(normalParam),
textureId: getOpenPBRTextureId(normalParam),
scale: extractOpenPBRValue(geometrySection.normal_map_scale || openPBR.normal_map_scale, 1.0)
};
return material;

View File

@@ -3,14 +3,34 @@ import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js';
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
import { LoaderUtils } from "three"
import { convertOpenPBRToMeshPhysicalMaterial } from './TinyUSDZMaterialX.js';
import { convertOpenPBRToMeshPhysicalMaterialLoaded } from './TinyUSDZMaterialX.js';
import { decodeEXR as decodeEXRWithFallback } from './EXRDecoder.js';
class TinyUSDZLoaderUtils extends LoaderUtils {
// Static reference to TinyUSDZ WASM module for EXR fallback
static _tinyusdz = null;
constructor() {
super();
}
/**
* Set TinyUSDZ WASM module for EXR decoding fallback
* @param {Object} tinyusdz - TinyUSDZ WASM module instance
*/
static setTinyUSDZ(tinyusdz) {
TinyUSDZLoaderUtils._tinyusdz = tinyusdz;
}
/**
* Get TinyUSDZ WASM module
* @returns {Object|null}
*/
static getTinyUSDZ() {
return TinyUSDZLoaderUtils._tinyusdz;
}
static async getDataFromURI(uri) {
try {
const response = await fetch(url);
@@ -105,6 +125,14 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46) {
return 'image/webp';
}
// EXR magic bytes: 76 2F 31 01
if (data[0] === 0x76 && data[1] === 0x2F && data[2] === 0x31 && data[3] === 0x01) {
return 'image/x-exr';
}
// HDR magic bytes: "#?" (Radiance format)
if (data[0] === 0x23 && data[1] === 0x3F) {
return 'image/vnd.radiance';
}
}
// Default fallback
@@ -127,12 +155,18 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
if (texImage.uri && (texImage.bufferId == -1)) {
// Case 1: URI only
const lowerUri = texImage.uri.toLowerCase();
const loader = new THREE.TextureLoader();
//console.log("Loading texture from URI:", texImage.uri);
// TODO: Use HDR/EXR loader if a uri is HDR/EXR file.
return loader.loadAsync(texImage.uri);
if (lowerUri.endsWith('.exr')) {
// EXR: Use EXRLoader
return new EXRLoader().loadAsync(texImage.uri);
} else if (lowerUri.endsWith('.hdr')) {
// HDR: Use HDRLoader
return new HDRLoader().loadAsync(texImage.uri);
} else {
// Standard image
return new THREE.TextureLoader().loadAsync(texImage.uri);
}
} else if (texImage.bufferId >= 0 && texImage.data) {
//console.log("case 2 or 3");
@@ -160,19 +194,59 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
return Promise.resolve(texture);
} else {
//console.log("case 3");
// Case 2: Embedded but not decoded - check format
try {
const blob = new Blob([texImage.data], { type: this.getMimeType(texImage) });
const blobUrl = URL.createObjectURL(blob);
const mimeType = this.getMimeType(texImage);
const loader = new THREE.TextureLoader();
//console.log("blobUrl", blobUrl);
// TODO: Use HDR/EXR loader if a uri is HDR/EXR file.
return loader.loadAsync(blobUrl);
// Check if HDR/EXR format - use specialized decoders
if (mimeType === 'image/x-exr') {
// EXR: Use TinyUSDZ fallback decoder
const texture = this.decodeEXRFromBuffer(texImage.data, 'float16');
if (texture) {
texture.flipY = true;
return Promise.resolve(texture);
}
// Fallback to Three.js EXRLoader with blob URL
const blob = new Blob([texImage.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
return new EXRLoader().loadAsync(blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
} else if (mimeType === 'image/vnd.radiance') {
// HDR: Use TinyUSDZ decoder (faster)
const tinyusdz = TinyUSDZLoaderUtils._tinyusdz;
if (tinyusdz && typeof tinyusdz.decodeHDR === 'function') {
const uint8Array = texImage.data instanceof Uint8Array
? texImage.data
: new Uint8Array(texImage.data);
const result = tinyusdz.decodeHDR(uint8Array, 'float16');
if (result.success) {
const texture = new THREE.DataTexture(
result.data,
result.width,
result.height,
THREE.RGBAFormat,
THREE.HalfFloatType
);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.flipY = true;
texture.needsUpdate = true;
return Promise.resolve(texture);
}
}
// Fallback to Three.js HDRLoader
const blob = new Blob([texImage.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
return new HDRLoader().loadAsync(blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
} else {
// Standard image format
const blob = new Blob([texImage.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
const loader = new THREE.TextureLoader();
return loader.loadAsync(blobUrl).finally(() => URL.revokeObjectURL(blobUrl));
}
} catch (error) {
console.error("Failed to create Blob from texture data:", error);
return Promise.reject(new Error("Failed to create Blob from texture data"));
console.error("Failed to decode texture data:", error);
return Promise.reject(new Error("Failed to decode texture data"));
}
}
@@ -476,8 +550,8 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
}
try {
// Use the TinyUSDZMaterialX converter
const material = await convertOpenPBRToMeshPhysicalMaterial(parsedMaterial, usdScene, {
// Use the TinyUSDZMaterialX converter (Loaded version waits for textures)
const material = await convertOpenPBRToMeshPhysicalMaterialLoaded(parsedMaterial, usdScene, {
envMap: options.envMap || null,
envMapIntensity: options.envMapIntensity || 1.0,
textureCache: options.textureCache || new Map()
@@ -1205,33 +1279,109 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
}
}
/**
* Decode EXR from buffer with Three.js primary + TinyUSDZ fallback
* @param {ArrayBuffer|Uint8Array} buffer - EXR data
* @param {string} [outputFormat='float16'] - Output format
* @returns {THREE.DataTexture|null}
*/
static decodeEXRFromBuffer(buffer, outputFormat = 'float16') {
const result = decodeEXRWithFallback(buffer, TinyUSDZLoaderUtils._tinyusdz, {
outputFormat,
preferThreeJS: true,
verbose: false,
});
if (!result.success) {
console.warn('EXR decode failed:', result.error);
return null;
}
// Create Three.js DataTexture from decoded data
const texture = new THREE.DataTexture(
result.data,
result.width,
result.height,
THREE.RGBAFormat,
result.format === 'float16' ? THREE.HalfFloatType : THREE.FloatType
);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
}
/**
* Decode environment map from buffer (supports EXR, HDR, and standard image formats)
* Uses Three.js EXRLoader with TinyUSDZ fallback for EXR files
*/
static async decodeEnvmapFromBuffer(buffer, uri) {
try {
const lowerUri = uri.toLowerCase();
const mimeType = this.getMimeTypeFromExtension(this.getFileExtension(uri)) || 'application/octet-stream';
const blob = new Blob([buffer], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
let texture = null;
try {
if (lowerUri.endsWith('.exr')) {
texture = await new EXRLoader().loadAsync(objectUrl);
} else if (lowerUri.endsWith('.hdr')) {
texture = await new HDRLoader().loadAsync(objectUrl);
} else {
texture = await new THREE.TextureLoader().loadAsync(objectUrl);
if (lowerUri.endsWith('.exr')) {
// Use EXR decoder with TinyUSDZ fallback
texture = this.decodeEXRFromBuffer(buffer, 'float16');
if (!texture) {
// Last resort: try Three.js EXRLoader with blob URL
const blob = new Blob([buffer], { type: 'image/x-exr' });
const objectUrl = URL.createObjectURL(blob);
try {
texture = await new EXRLoader().loadAsync(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
} else if (lowerUri.endsWith('.hdr')) {
// HDR uses TinyUSDZ decoder (faster than Three.js)
const tinyusdz = TinyUSDZLoaderUtils._tinyusdz;
if (tinyusdz && typeof tinyusdz.decodeHDR === 'function') {
const uint8Array = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
const result = tinyusdz.decodeHDR(uint8Array, 'float16');
if (result.success) {
texture = new THREE.DataTexture(
result.data,
result.width,
result.height,
THREE.RGBAFormat,
THREE.HalfFloatType
);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
}
}
if (texture) {
texture.mapping = THREE.EquirectangularReflectionMapping;
if (!texture) {
// Fallback to Three.js HDRLoader
const blob = new Blob([buffer], { type: 'image/vnd.radiance' });
const objectUrl = URL.createObjectURL(blob);
try {
texture = await new HDRLoader().loadAsync(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
} finally {
URL.revokeObjectURL(objectUrl);
} else {
// Standard image formats
const mimeType = this.getMimeTypeFromExtension(this.getFileExtension(uri)) || 'application/octet-stream';
const blob = new Blob([buffer], { type: mimeType });
const objectUrl = URL.createObjectURL(blob);
try {
texture = await new THREE.TextureLoader().loadAsync(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
}
if (texture) {
texture.mapping = THREE.EquirectangularReflectionMapping;
}
return texture;
@@ -1297,6 +1447,7 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
/**
* Load DomeLight environment map directly from file
* Uses TinyUSDZ for HDR (faster) and Three.js + TinyUSDZ fallback for EXR
* @param {Object} light - USD light data
* @param {string} textureFile - Texture file path
* @param {THREE.PMREMGenerator} pmremGenerator - PMREM generator
@@ -1307,9 +1458,10 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
let texture = null;
const lowerFile = textureFile.toLowerCase();
// Pre-check if file exists and has valid content type
// Fetch the file data
let response;
try {
const response = await fetch(textureFile, { method: 'HEAD' });
response = await fetch(textureFile);
if (!response.ok) {
console.warn(`DomeLight: Texture file not accessible '${textureFile}' (HTTP ${response.status})`);
return null;
@@ -1325,28 +1477,71 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
return null;
}
const buffer = await response.arrayBuffer();
if (lowerFile.endsWith('.exr')) {
try {
texture = await new EXRLoader().loadAsync(textureFile);
} catch (exrError) {
console.warn(`DomeLight: EXR load failed for '${textureFile}' - ${exrError.message}`);
return null;
// Use EXR decoder with TinyUSDZ fallback
texture = this.decodeEXRFromBuffer(buffer, 'float16');
if (!texture) {
// Fallback: try Three.js EXRLoader with blob URL
try {
const blob = new Blob([buffer], { type: 'image/x-exr' });
const objectUrl = URL.createObjectURL(blob);
try {
texture = await new EXRLoader().loadAsync(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
} catch (exrError) {
console.warn(`DomeLight: EXR load failed for '${textureFile}' - ${exrError.message}`);
return null;
}
}
} else if (lowerFile.endsWith('.hdr')) {
try {
texture = await new HDRLoader().loadAsync(textureFile);
} catch (hdrError) {
console.warn(`DomeLight: HDR load failed for '${textureFile}' - ${hdrError.message}`);
return null;
// Use TinyUSDZ HDR decoder (faster than Three.js)
const tinyusdz = TinyUSDZLoaderUtils._tinyusdz;
if (tinyusdz && typeof tinyusdz.decodeHDR === 'function') {
const uint8Array = new Uint8Array(buffer);
const result = tinyusdz.decodeHDR(uint8Array, 'float16');
if (result.success) {
texture = new THREE.DataTexture(
result.data,
result.width,
result.height,
THREE.RGBAFormat,
THREE.HalfFloatType
);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.needsUpdate = true;
}
}
if (!texture) {
// Fallback to Three.js HDRLoader
try {
const blob = new Blob([buffer], { type: 'image/vnd.radiance' });
const objectUrl = URL.createObjectURL(blob);
try {
texture = await new HDRLoader().loadAsync(objectUrl);
} finally {
URL.revokeObjectURL(objectUrl);
}
} catch (hdrError) {
console.warn(`DomeLight: HDR load failed for '${textureFile}' - ${hdrError.message}`);
return null;
}
}
} else {
console.warn(`DomeLight: Unsupported texture format for '${textureFile}'`);
return null;
}
// Check if texture was loaded and has valid image data
if (!texture || !texture.image) {
console.warn(`DomeLight: Texture loaded but has no image data for '${textureFile}'`);
// Check if texture was loaded and has valid data
if (!texture) {
console.warn(`DomeLight: Failed to decode texture for '${textureFile}'`);
return null;
}

View File

@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { decodeEXR as decodeEXRWithFallback } from './EXRDecoder.js';
/**
* TinyUSDZ MaterialX / OpenPBR Material Utilities
@@ -8,12 +9,23 @@ import * as THREE from 'three';
*
* Key features:
* - OpenPBR parameter extraction from JSON format (flat and grouped)
* - Texture loading from USD scene with caching
* - Texture loading from USD scene with caching (including EXR/HDR)
* - Full OpenPBR to MeshPhysicalMaterial conversion
* - Support for all OpenPBR layers: base, specular, transmission, subsurface,
* coat, sheen, fuzz, thin_film, emission, geometry
*/
// Reference to TinyUSDZ WASM module for HDR/EXR decoding
let _tinyusdz = null;
/**
* Set TinyUSDZ WASM module for texture decoding
* @param {Object} tinyusdz - TinyUSDZ WASM module
*/
export function setTinyUSDZ(tinyusdz) {
_tinyusdz = tinyusdz;
}
// ============================================================================
// OpenPBR Parameter Mapping to Three.js MeshPhysicalMaterial
// ============================================================================
@@ -166,14 +178,86 @@ function getMimeType(imgData) {
// Check magic bytes
if (imgData.data && imgData.data.length >= 4) {
const data = new Uint8Array(imgData.data);
// PNG magic: 0x89 0x50 0x4E 0x47
if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47) return 'image/png';
// JPEG magic: 0xFF 0xD8 0xFF
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) return 'image/jpeg';
// WEBP magic: RIFF....WEBP
if (data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46) return 'image/webp';
// EXR magic: 0x76 0x2F 0x31 0x01
if (data[0] === 0x76 && data[1] === 0x2F && data[2] === 0x31 && data[3] === 0x01) return 'image/x-exr';
// HDR magic: "#?" (Radiance format)
if (data[0] === 0x23 && data[1] === 0x3F) return 'image/vnd.radiance';
}
return 'image/png';
}
/**
* Check if MIME type is HDR format (EXR or HDR)
*/
function isHDRFormat(mimeType) {
return mimeType === 'image/x-exr' || mimeType === 'image/vnd.radiance';
}
/**
* Decode HDR/EXR texture data and create Three.js DataTexture
* @param {Uint8Array|ArrayBuffer} data - Image data
* @param {string} mimeType - MIME type
* @returns {THREE.DataTexture|null}
*/
function decodeHDRTexture(data, mimeType) {
const buffer = data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
if (mimeType === 'image/x-exr') {
// Use EXR decoder with TinyUSDZ fallback
const result = decodeEXRWithFallback(buffer, _tinyusdz, {
outputFormat: 'float16',
preferThreeJS: true,
});
if (result.success) {
const texture = new THREE.DataTexture(
result.data,
result.width,
result.height,
THREE.RGBAFormat,
result.format === 'float16' ? THREE.HalfFloatType : THREE.FloatType
);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.flipY = true;
texture.needsUpdate = true;
return texture;
}
} else if (mimeType === 'image/vnd.radiance') {
// Use TinyUSDZ HDR decoder (faster)
if (_tinyusdz && typeof _tinyusdz.decodeHDR === 'function') {
const uint8Array = data instanceof Uint8Array ? data : new Uint8Array(buffer);
const result = _tinyusdz.decodeHDR(uint8Array, 'float16');
if (result.success) {
const texture = new THREE.DataTexture(
result.data,
result.width,
result.height,
THREE.RGBAFormat,
THREE.HalfFloatType
);
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
texture.flipY = true;
texture.needsUpdate = true;
return texture;
}
}
}
return null;
}
// ============================================================================
// Texture Loading
// ============================================================================
@@ -233,11 +317,16 @@ async function loadTextureFromUSD(usdScene, textureId, cache = null) {
texture.needsUpdate = true;
} else {
const mimeType = getMimeType(altImgData);
const blob = new Blob([altImgData.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
const loader = new THREE.TextureLoader();
texture = await loader.loadAsync(blobUrl);
URL.revokeObjectURL(blobUrl);
// Check if HDR format - use specialized decoder
if (isHDRFormat(mimeType)) {
texture = decodeHDRTexture(altImgData.data, mimeType);
} else {
const blob = new Blob([altImgData.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
const loader = new THREE.TextureLoader();
texture = await loader.loadAsync(blobUrl);
URL.revokeObjectURL(blobUrl);
}
}
foundEmbedded = true;
break;
@@ -270,14 +359,20 @@ async function loadTextureFromUSD(usdScene, textureId, cache = null) {
texture.flipY = true;
texture.needsUpdate = true;
} else {
// Needs decoding - create Blob and load
// Needs decoding - check format and decode
const mimeType = getMimeType(imgData);
const blob = new Blob([imgData.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
const loader = new THREE.TextureLoader();
texture = await loader.loadAsync(blobUrl);
URL.revokeObjectURL(blobUrl);
// Check if HDR format - use specialized decoder
if (isHDRFormat(mimeType)) {
texture = decodeHDRTexture(imgData.data, mimeType);
} else {
// Standard image - use Blob and TextureLoader
const blob = new Blob([imgData.data], { type: mimeType });
const blobUrl = URL.createObjectURL(blob);
const loader = new THREE.TextureLoader();
texture = await loader.loadAsync(blobUrl);
URL.revokeObjectURL(blobUrl);
}
}
}
@@ -299,13 +394,14 @@ async function loadTextureFromUSD(usdScene, textureId, cache = null) {
/**
* Convert OpenPBR material data to Three.js MeshPhysicalMaterial
* Waits for all textures to load before returning the material.
*
* @param {Object} materialData - OpenPBR material data from USD
* @param {Object} usdScene - USD scene for texture loading
* @param {Object} options - Conversion options
* @returns {Promise<THREE.MeshPhysicalMaterial>}
*/
async function convertOpenPBRToMeshPhysicalMaterial(materialData, usdScene = null, options = {}) {
async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene = null, options = {}) {
const material = new THREE.MeshPhysicalMaterial();
// Store texture references for later management
@@ -582,6 +678,320 @@ async function convertOpenPBRToMeshPhysicalMaterial(materialData, usdScene = nul
return material;
}
/**
* Convert OpenPBR material data to Three.js MeshPhysicalMaterial (legacy pattern)
* Returns material immediately, textures load asynchronously in the background.
* This matches the behavior of convertUsdMaterialToMeshPhysicalMaterial.
*
* @param {Object} materialData - OpenPBR material data from USD
* @param {Object} usdScene - USD scene for texture loading
* @param {Object} options - Conversion options
* @returns {THREE.MeshPhysicalMaterial} Material (textures load asynchronously)
*/
function convertOpenPBRToMeshPhysicalMaterial(materialData, usdScene = null, options = {}) {
const material = new THREE.MeshPhysicalMaterial();
// Store texture references for later management
material.userData.textures = {};
material.userData.materialType = 'OpenPBR';
material.userData.openPBRData = materialData;
// Get OpenPBR data - support multiple formats
let pbr = null;
// Check for grouped format first
if (materialData.openPBR) {
pbr = materialData.openPBR;
material.userData.format = 'grouped';
}
// Check for flat format
else if (materialData.base_color !== undefined ||
materialData.base_metalness !== undefined ||
materialData.specular_roughness !== undefined) {
pbr = { flat: materialData };
material.userData.format = 'flat';
}
// Check for openPBRShader format
else if (materialData.openPBRShader) {
pbr = { flat: materialData.openPBRShader };
material.userData.format = 'flat';
}
if (!pbr) {
console.warn('No OpenPBR data found in material');
return material;
}
// Texture cache
const textureCache = options.textureCache || new Map();
// Helper to apply parameter value (sync) and queue texture loading (async)
const applyParam = (paramName, paramValue) => {
const mapping = OPENPBR_TO_THREEJS_MAP[paramName];
if (!mapping || !mapping.property) return;
const value = extractValue(paramValue);
// Apply scalar or color value immediately
if (mapping.type === 'color' && Array.isArray(value)) {
material[mapping.property] = createColor(value);
} else if (mapping.type === 'scalar' && typeof value === 'number') {
material[mapping.property] = value;
}
// Queue texture loading (fire-and-forget)
if (usdScene && hasTexture(paramValue)) {
const texMapName = OPENPBR_TEXTURE_MAP[paramName];
if (texMapName) {
loadTextureFromUSD(usdScene, getTextureId(paramValue), textureCache).then((texture) => {
if (texture) {
material[texMapName] = texture;
material.userData.textures[texMapName] = {
textureId: getTextureId(paramValue),
texture: texture
};
material.needsUpdate = true;
}
}).catch((err) => {
console.error(`Failed to load texture for ${paramName}:`, err);
});
}
}
};
// Process flat format
if (pbr.flat) {
const flat = pbr.flat;
// Base layer
applyParam('base_color', flat.base_color);
applyParam('base_metalness', flat.base_metalness);
// Specular layer
applyParam('specular_roughness', flat.specular_roughness);
applyParam('specular_ior', flat.specular_ior);
applyParam('specular_color', flat.specular_color);
applyParam('specular_anisotropy', flat.specular_anisotropy);
// Transmission
applyParam('transmission_weight', flat.transmission_weight);
applyParam('transmission_color', flat.transmission_color);
// Coat
applyParam('coat_weight', flat.coat_weight);
applyParam('coat_roughness', flat.coat_roughness);
// Sheen/Fuzz
if (flat.sheen_weight !== undefined) {
applyParam('sheen_weight', flat.sheen_weight);
applyParam('sheen_color', flat.sheen_color);
applyParam('sheen_roughness', flat.sheen_roughness);
} else if (flat.fuzz_weight !== undefined) {
applyParam('fuzz_weight', flat.fuzz_weight);
applyParam('fuzz_color', flat.fuzz_color);
applyParam('fuzz_roughness', flat.fuzz_roughness);
}
// Thin film (iridescence)
if (flat.thin_film_weight !== undefined) {
const weight = extractValue(flat.thin_film_weight);
if (weight > 0) {
material.iridescence = weight;
const thickness = extractValue(flat.thin_film_thickness) || 500;
material.iridescenceThicknessRange = [100, thickness];
material.iridescenceIOR = extractValue(flat.thin_film_ior) || 1.5;
}
}
// Emission
if (flat.emission_color !== undefined) {
const emissionColor = extractValue(flat.emission_color);
if (emissionColor && Array.isArray(emissionColor)) {
material.emissive = createColor(emissionColor);
}
// Load emission texture (fire-and-forget)
if (usdScene && hasTexture(flat.emission_color)) {
loadTextureFromUSD(usdScene, getTextureId(flat.emission_color), textureCache).then((texture) => {
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId: getTextureId(flat.emission_color), texture };
material.needsUpdate = true;
}
}).catch((err) => {
console.error('Failed to load emission texture:', err);
});
}
}
if (flat.emission_luminance !== undefined) {
material.emissiveIntensity = extractValue(flat.emission_luminance) || 0;
}
// Geometry - opacity/alpha
const opacityParam = flat.opacity !== undefined ? flat.opacity : flat.geometry_opacity;
if (opacityParam !== undefined) {
const opacityValue = extractValue(opacityParam);
if (typeof opacityValue === 'number') {
material.opacity = opacityValue;
material.transparent = opacityValue < 1.0;
}
if (usdScene && hasTexture(opacityParam)) {
loadTextureFromUSD(usdScene, getTextureId(opacityParam), textureCache).then((texture) => {
if (texture) {
material.alphaMap = texture;
material.transparent = true;
material.userData.textures.alphaMap = { textureId: getTextureId(opacityParam), texture };
material.needsUpdate = true;
}
}).catch((err) => {
console.error('Failed to load opacity texture:', err);
});
}
}
// Normal map
const normalParam = flat.normal !== undefined ? flat.normal : flat.geometry_normal;
if (normalParam !== undefined && usdScene && hasTexture(normalParam)) {
loadTextureFromUSD(usdScene, getTextureId(normalParam), textureCache).then((texture) => {
if (texture) {
material.normalMap = texture;
material.normalScale = new THREE.Vector2(1, 1);
material.userData.textures.normalMap = { textureId: getTextureId(normalParam), texture };
material.needsUpdate = true;
}
}).catch((err) => {
console.error('Failed to load normal texture:', err);
});
}
}
// Process grouped format
else {
// Base layer
if (pbr.base) {
applyParam('base_color', pbr.base.base_color);
applyParam('base_metalness', pbr.base.base_metalness);
}
// Specular layer
if (pbr.specular) {
applyParam('specular_roughness', pbr.specular.specular_roughness);
applyParam('specular_ior', pbr.specular.specular_ior);
applyParam('specular_color', pbr.specular.specular_color);
applyParam('specular_anisotropy', pbr.specular.specular_anisotropy);
}
// Transmission
if (pbr.transmission) {
applyParam('transmission_weight', pbr.transmission.transmission_weight);
applyParam('transmission_color', pbr.transmission.transmission_color);
}
// Coat
if (pbr.coat) {
applyParam('coat_weight', pbr.coat.coat_weight);
applyParam('coat_roughness', pbr.coat.coat_roughness);
}
// Sheen
if (pbr.sheen) {
applyParam('sheen_weight', pbr.sheen.sheen_weight);
applyParam('sheen_color', pbr.sheen.sheen_color);
applyParam('sheen_roughness', pbr.sheen.sheen_roughness);
}
// Fuzz
if (pbr.fuzz) {
applyParam('fuzz_weight', pbr.fuzz.fuzz_weight);
applyParam('fuzz_color', pbr.fuzz.fuzz_color);
applyParam('fuzz_roughness', pbr.fuzz.fuzz_roughness);
}
// Thin film
if (pbr.thin_film) {
const weight = extractValue(pbr.thin_film.thin_film_weight);
if (weight > 0) {
material.iridescence = weight;
const thickness = extractValue(pbr.thin_film.thin_film_thickness) || 500;
material.iridescenceThicknessRange = [100, thickness];
material.iridescenceIOR = extractValue(pbr.thin_film.thin_film_ior) || 1.5;
}
}
// Emission
if (pbr.emission) {
const emissionColor = extractValue(pbr.emission.emission_color);
if (emissionColor && Array.isArray(emissionColor)) {
material.emissive = createColor(emissionColor);
}
if (usdScene && hasTexture(pbr.emission.emission_color)) {
loadTextureFromUSD(usdScene, getTextureId(pbr.emission.emission_color), textureCache).then((texture) => {
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId: getTextureId(pbr.emission.emission_color), texture };
material.needsUpdate = true;
}
}).catch((err) => {
console.error('Failed to load emission texture:', err);
});
}
if (pbr.emission.emission_luminance !== undefined) {
material.emissiveIntensity = extractValue(pbr.emission.emission_luminance) || 0;
}
}
// Geometry
if (pbr.geometry) {
const opacityParam = pbr.geometry.opacity !== undefined ? pbr.geometry.opacity : pbr.geometry.geometry_opacity;
if (opacityParam !== undefined) {
const opacityValue = extractValue(opacityParam);
if (typeof opacityValue === 'number') {
material.opacity = opacityValue;
material.transparent = opacityValue < 1.0;
}
if (usdScene && hasTexture(opacityParam)) {
loadTextureFromUSD(usdScene, getTextureId(opacityParam), textureCache).then((texture) => {
if (texture) {
material.alphaMap = texture;
material.transparent = true;
material.userData.textures.alphaMap = { textureId: getTextureId(opacityParam), texture };
material.needsUpdate = true;
}
}).catch((err) => {
console.error('Failed to load opacity texture:', err);
});
}
}
const normalParam = pbr.geometry.normal !== undefined ? pbr.geometry.normal : pbr.geometry.geometry_normal;
if (normalParam !== undefined && usdScene && hasTexture(normalParam)) {
loadTextureFromUSD(usdScene, getTextureId(normalParam), textureCache).then((texture) => {
if (texture) {
material.normalMap = texture;
material.normalScale = new THREE.Vector2(1, 1);
material.userData.textures.normalMap = { textureId: getTextureId(normalParam), texture };
material.needsUpdate = true;
}
}).catch((err) => {
console.error('Failed to load normal texture:', err);
});
}
}
}
// Apply environment map if provided
if (options.envMap) {
material.envMap = options.envMap;
material.envMapIntensity = options.envMapIntensity || 1.0;
}
// Set material name
if (materialData.name) {
material.name = materialData.name;
}
return material;
}
// ============================================================================
// TinyUSDZOpenPBR Class (Legacy compatibility)
// ============================================================================
@@ -646,6 +1056,7 @@ class TinyUSDZOpenPBR {
export {
TinyUSDZOpenPBR,
convertOpenPBRToMeshPhysicalMaterial,
convertOpenPBRToMeshPhysicalMaterialLoaded,
loadTextureFromUSD,
extractValue,
hasTexture,