Add BRDF Visualizer (Priority 4 feature #5 - FINAL)

Implements interactive 3D BRDF (Bidirectional Reflectance Distribution Function) visualization:

New file: brdf-visualizer.js (433 lines)
- Real-time 3D lobe visualization of material reflection behavior
- GGX microfacet BRDF model implementation
- Configurable view and light angles (0-90 degrees)
- Heatmap color coding (blue=low, red=high intensity)
- Adjustable resolution (32×64, 64×128, 128×256 vertices)
- Floating overlay panel (top-right, 320×256px canvas)

BRDF Implementation:
- **GGX Normal Distribution**: D = α² / (π * ((NdotH)² * (α² - 1) + 1)²)
- **Geometry Term**: Smith's G with k = (roughness + 1)² / 8
- **Fresnel**: Schlick approximation F = F0 + (1 - F0) * (1 - VdotH)⁵
- **Specular**: (D * G * F) / (4 * NdotV * NdotL)
- **Diffuse**: (1 - metalness) * (1 - F) / π

Visualization features:
- 3D lobe shape represents reflection intensity by direction
- Lobe height/radius = reflection strength in that direction
- Lobe width = spread of reflections (controlled by roughness)
- Color mapping: Black → Blue → Cyan → Yellow → Red
- Supports metallic and dielectric materials

Material analysis:
- Metalness classification (metal vs dielectric vs mixed)
- Roughness interpretation (glossy, medium, rough, matte)
- Surface characteristic descriptions
- BRDF lobe characteristics
- Physically-based validation warnings

Display overlay:
- 256×256 pixel canvas with 3D BRDF lobe
- Real-time material properties display:
  - Roughness value
  - Metalness value
  - View angle (0-90°)
  - Light angle (0-90°)
  - Resolution settings

Interactive controls:
- View angle slider: Camera/view position relative to surface normal
- Light angle slider: Light source position relative to normal
- Resolution selector: Trade quality vs performance
- Update material: Refresh from selected object
- Analyze and log: Console output with details
- Export report: Markdown analysis file

Report generation:
- Material type classification (metal/dielectric/mixed)
- Surface characteristics based on roughness
- BRDF lobe interpretation guide
- Physical correctness warnings
- Visualization settings documentation

Integration:
- Added import to materialx.js (line 67)
- Added GUI folder with 7 controls (lines 4171-4284):
  - Enable/disable toggle
  - View angle slider (0-90°)
  - Light angle slider (0-90°)
  - Resolution selector (32, 64, 128)
  - Update from selected object
  - Analyze and log
  - Export report
- Added window export (line 6642)
- Added script tag to materialx.html (line 1303)

Use cases:
- Understand how roughness affects reflection spread
- Visualize metallic vs dielectric reflection behavior
- Debug material appearance issues
- Educational tool for PBR theory
- Validate material parameter ranges
- Compare BRDF shapes between materials

Technical notes:
- Uses simplified GGX model (not full path tracing)
- Renders to separate scene, composited to canvas
- Preserves main renderer state
- Dynamic geometry generation based on parameters
- Vertex coloring for heatmap visualization

Priority 4 Progress: 5/5 features complete (100%) 🎉

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Syoyo Fujita
2025-11-21 04:14:52 +09:00
parent 7c6a7c5b12
commit 9aaf7153a7
3 changed files with 553 additions and 0 deletions

435
web/js/brdf-visualizer.js Normal file
View File

@@ -0,0 +1,435 @@
// BRDF Visualizer
// Interactive 3D visualization of Bidirectional Reflectance Distribution Function
import * as THREE from 'three';
export class BRDFVisualizer {
constructor(renderer) {
this.renderer = renderer;
this.enabled = false;
// BRDF visualization parameters
this.material = null;
this.viewAngle = 0; // 0-90 degrees (0 = normal, 90 = grazing)
this.lightAngle = 45; // 0-90 degrees
this.resolution = 64;
// Visualization objects
this.brdfScene = null;
this.brdfCamera = null;
this.brdfMesh = null;
this.canvas = null;
this.texture = null;
// Display overlay
this.overlayDiv = null;
}
// Enable BRDF visualizer
enable() {
this.enabled = true;
this.createOverlay();
}
// Disable BRDF visualizer
disable() {
this.enabled = false;
this.removeOverlay();
}
// Set material to visualize
setMaterial(material) {
this.material = material;
if (this.enabled) {
this.updateVisualization();
}
}
// Set view angle (0-90 degrees from normal)
setViewAngle(angle) {
this.viewAngle = Math.max(0, Math.min(90, angle));
if (this.enabled) {
this.updateVisualization();
}
}
// Set light angle (0-90 degrees from normal)
setLightAngle(angle) {
this.lightAngle = Math.max(0, Math.min(90, angle));
if (this.enabled) {
this.updateVisualization();
}
}
// Create overlay panel
createOverlay() {
this.overlayDiv = document.createElement('div');
this.overlayDiv.id = 'brdf-visualizer-overlay';
this.overlayDiv.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
width: 320px;
background: rgba(0, 0, 0, 0.9);
border: 2px solid #4CAF50;
border-radius: 8px;
padding: 15px;
color: #fff;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 13px;
z-index: 10000;
`;
this.overlayDiv.innerHTML = `
<h3 style="margin: 0 0 10px 0; color: #4CAF50;">BRDF Visualizer</h3>
<canvas id="brdf-canvas" width="256" height="256" style="width: 100%; border: 1px solid #666;"></canvas>
<div id="brdf-info" style="margin-top: 10px; font-size: 11px; color: #aaa;"></div>
`;
document.body.appendChild(this.overlayDiv);
this.canvas = document.getElementById('brdf-canvas');
this.updateVisualization();
}
// Remove overlay panel
removeOverlay() {
if (this.overlayDiv && this.overlayDiv.parentElement) {
this.overlayDiv.parentElement.removeChild(this.overlayDiv);
}
this.overlayDiv = null;
this.canvas = null;
}
// Update BRDF visualization
updateVisualization() {
if (!this.canvas || !this.material) return;
// Create BRDF scene
if (!this.brdfScene) {
this.brdfScene = new THREE.Scene();
this.brdfCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
this.brdfCamera.position.set(0, 0, 1);
this.brdfCamera.lookAt(0, 0, 0);
}
// Generate BRDF lobe visualization
this.generateBRDFLobe();
// Render to canvas
this.renderToCanvas();
}
// Generate BRDF lobe geometry
generateBRDFLobe() {
// Remove existing mesh
if (this.brdfMesh) {
this.brdfScene.remove(this.brdfMesh);
this.brdfMesh.geometry.dispose();
this.brdfMesh.material.dispose();
}
// Create geometry for BRDF lobe
const geometry = new THREE.BufferGeometry();
const vertices = [];
const colors = [];
const segments = this.resolution;
const thetaSegments = segments;
const phiSegments = segments * 2;
// Convert angles to radians
const viewTheta = this.viewAngle * Math.PI / 180;
const lightTheta = this.lightAngle * Math.PI / 180;
// View direction (incident)
const viewDir = new THREE.Vector3(
Math.sin(viewTheta),
0,
Math.cos(viewTheta)
);
// Light direction
const lightDir = new THREE.Vector3(
Math.sin(lightTheta),
0,
Math.cos(lightTheta)
);
// Generate BRDF lobe points
const maxRadius = 0.8;
for (let t = 0; t <= thetaSegments; t++) {
const theta = (t / thetaSegments) * Math.PI / 2; // 0 to 90 degrees
for (let p = 0; p <= phiSegments; p++) {
const phi = (p / phiSegments) * Math.PI * 2; // 0 to 360 degrees
// Outgoing direction (reflection)
const outDir = new THREE.Vector3(
Math.sin(theta) * Math.cos(phi),
Math.sin(theta) * Math.sin(phi),
Math.cos(theta)
);
// Calculate BRDF value (simplified approximation)
const brdfValue = this.evaluateBRDF(viewDir, lightDir, outDir);
// Scale by BRDF value
const radius = brdfValue * maxRadius;
// Position
const x = outDir.x * radius;
const y = outDir.y * radius;
const z = outDir.z * radius;
vertices.push(x, y, z);
// Color based on BRDF value (heatmap)
const color = this.valueToColor(brdfValue);
colors.push(color.r, color.g, color.b);
}
}
// Create indices for triangles
const indices = [];
for (let t = 0; t < thetaSegments; t++) {
for (let p = 0; p < phiSegments; p++) {
const i0 = t * (phiSegments + 1) + p;
const i1 = i0 + 1;
const i2 = i0 + (phiSegments + 1);
const i3 = i2 + 1;
indices.push(i0, i2, i1);
indices.push(i1, i2, i3);
}
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
geometry.setIndex(indices);
geometry.computeVertexNormals();
const material = new THREE.MeshBasicMaterial({
vertexColors: true,
side: THREE.DoubleSide,
wireframe: false
});
this.brdfMesh = new THREE.Mesh(geometry, material);
this.brdfScene.add(this.brdfMesh);
// Update info display
this.updateInfoDisplay();
}
// Simplified BRDF evaluation (GGX microfacet model approximation)
evaluateBRDF(viewDir, lightDir, outDir) {
if (!this.material) return 0;
const normal = new THREE.Vector3(0, 0, 1);
// Calculate half vector
const halfVec = new THREE.Vector3()
.addVectors(lightDir, outDir)
.normalize();
const NdotH = Math.max(0, normal.dot(halfVec));
const NdotV = Math.max(0, normal.dot(viewDir));
const NdotL = Math.max(0, normal.dot(lightDir));
const VdotH = Math.max(0, viewDir.dot(halfVec));
if (NdotL <= 0 || NdotV <= 0) return 0;
// Get material parameters
const roughness = this.material.roughness !== undefined ? this.material.roughness : 0.5;
const metalness = this.material.metalness !== undefined ? this.material.metalness : 0.0;
// GGX normal distribution
const alpha = roughness * roughness;
const alphaSq = alpha * alpha;
const denom = NdotH * NdotH * (alphaSq - 1) + 1;
const D = alphaSq / (Math.PI * denom * denom);
// Simplified geometry term
const k = (roughness + 1) * (roughness + 1) / 8;
const G1V = NdotV / (NdotV * (1 - k) + k);
const G1L = NdotL / (NdotL * (1 - k) + k);
const G = G1V * G1L;
// Fresnel (Schlick approximation)
const F0 = metalness * 0.04 + (1 - metalness) * 0.04;
const F = F0 + (1 - F0) * Math.pow(1 - VdotH, 5);
// Specular BRDF
const specular = (D * G * F) / (4 * NdotV * NdotL + 0.001);
// Diffuse component (Lambertian)
const diffuse = (1 - metalness) * (1 - F) / Math.PI;
return specular + diffuse;
}
// Convert BRDF value to heatmap color
valueToColor(value) {
// Normalize value (typically 0-2 for PBR)
const t = Math.min(1, value / 2);
const color = new THREE.Color();
if (t < 0.25) {
// Black to blue
color.setRGB(0, 0, t * 4);
} else if (t < 0.5) {
// Blue to cyan
const s = (t - 0.25) * 4;
color.setRGB(0, s, 1);
} else if (t < 0.75) {
// Cyan to yellow
const s = (t - 0.5) * 4;
color.setRGB(s, 1, 1 - s);
} else {
// Yellow to red
const s = (t - 0.75) * 4;
color.setRGB(1, 1 - s, 0);
}
return color;
}
// Render BRDF to canvas
renderToCanvas() {
if (!this.canvas || !this.brdfScene) return;
const width = this.canvas.width;
const height = this.canvas.height;
// Save original render target
const originalTarget = this.renderer.getRenderTarget();
const originalSize = this.renderer.getSize(new THREE.Vector2());
// Set canvas size
this.renderer.setSize(width, height, false);
// Render BRDF scene
this.renderer.setRenderTarget(null);
this.renderer.render(this.brdfScene, this.brdfCamera);
// Copy to canvas
const ctx = this.canvas.getContext('2d');
const glCanvas = this.renderer.domElement;
ctx.drawImage(glCanvas, 0, 0, width, height);
// Restore original render target and size
this.renderer.setRenderTarget(originalTarget);
this.renderer.setSize(originalSize.x, originalSize.y, false);
}
// Update info display
updateInfoDisplay() {
const infoDiv = document.getElementById('brdf-info');
if (!infoDiv || !this.material) return;
const roughness = this.material.roughness !== undefined ? this.material.roughness : 0.5;
const metalness = this.material.metalness !== undefined ? this.material.metalness : 0.0;
infoDiv.innerHTML = `
<strong>Material Properties:</strong><br>
Roughness: ${roughness.toFixed(3)}<br>
Metalness: ${metalness.toFixed(3)}<br>
<br>
<strong>Visualization:</strong><br>
View Angle: ${this.viewAngle.toFixed(1)}°<br>
Light Angle: ${this.lightAngle.toFixed(1)}°<br>
Resolution: ${this.resolution}×${this.resolution * 2}
`;
}
// Generate analysis report
generateReport() {
if (!this.material) {
return '# BRDF Analysis\n\n**Status**: No material selected.\n';
}
const roughness = this.material.roughness !== undefined ? this.material.roughness : 0.5;
const metalness = this.material.metalness !== undefined ? this.material.metalness : 0.0;
let report = '# BRDF Visualization Report\n\n';
report += '## Material Properties\n\n';
report += `**Roughness**: ${roughness.toFixed(3)}\n`;
report += `**Metalness**: ${metalness.toFixed(3)}\n`;
report += `**Material Type**: ${this.material.type}\n\n`;
report += '## BRDF Characteristics\n\n';
if (metalness > 0.9) {
report += '**Material Type**: Metal\n';
report += '- Specular reflections only (no diffuse)\n';
report += '- Colored reflections based on base color\n';
report += '- F0 determined by base color\n\n';
} else if (metalness < 0.1) {
report += '**Material Type**: Dielectric (Non-metal)\n';
report += '- Both diffuse and specular reflection\n';
report += '- Achromatic (white) specular\n';
report += '- F0 ≈ 0.04 (4% reflectance at normal incidence)\n\n';
} else {
report += '**Material Type**: Mixed (Physically Incorrect)\n';
report += '⚠️ Metalness should be binary: 0.0 or 1.0\n\n';
}
if (roughness < 0.1) {
report += '**Surface**: Very smooth / glossy\n';
report += '- Sharp, mirror-like reflections\n';
report += '- Tight specular lobe\n';
} else if (roughness < 0.5) {
report += '**Surface**: Smooth to medium\n';
report += '- Clear but slightly blurred reflections\n';
report += '- Moderate specular lobe width\n';
} else if (roughness < 0.8) {
report += '**Surface**: Rough\n';
report += '- Blurry reflections\n';
report += '- Wide specular lobe\n';
} else {
report += '**Surface**: Very rough / matte\n';
report += '- Diffuse-like appearance\n';
report += '- Very wide specular lobe\n';
}
report += '\n## Visualization Settings\n\n';
report += `**View Angle**: ${this.viewAngle.toFixed(1)}° from normal\n`;
report += `**Light Angle**: ${this.lightAngle.toFixed(1)}° from normal\n`;
report += `**Resolution**: ${this.resolution}×${this.resolution * 2}\n\n`;
report += '## Interpretation\n\n';
report += 'The 3D lobe shape represents how light reflects off the surface:\n';
report += '- **Height/Radius**: Reflection intensity in that direction\n';
report += '- **Width**: How spread out reflections are (roughness)\n';
report += '- **Color**: Intensity (blue=low, red=high)\n';
return report;
}
// Log analysis to console
logAnalysis() {
if (!this.material) {
console.warn('No material selected for BRDF analysis');
return;
}
const roughness = this.material.roughness !== undefined ? this.material.roughness : 0.5;
const metalness = this.material.metalness !== undefined ? this.material.metalness : 0.0;
console.group('📊 BRDF Analysis');
console.log(`Material Type: ${this.material.type}`);
console.log(`Roughness: ${roughness.toFixed(3)}`);
console.log(`Metalness: ${metalness.toFixed(3)}`);
console.log(`View Angle: ${this.viewAngle}°`);
console.log(`Light Angle: ${this.lightAngle}°`);
console.groupEnd();
}
}
// Make class globally accessible
if (typeof window !== 'undefined') {
window.BRDFVisualizer = BRDFVisualizer;
}

View File

@@ -1300,6 +1300,7 @@
<script type="module" src="texture-tiling-detector.js"></script>
<script type="module" src="gradient-ramp-editor.js"></script>
<script type="module" src="light-probe-visualizer.js"></script>
<script type="module" src="brdf-visualizer.js"></script>
<!-- Main application script as module -->
<script type="module" src="materialx.js"></script>

View File

@@ -64,6 +64,7 @@ import { PBRTheoryGuide } from './pbr-theory-guide.js';
import { TextureTilingDetector } from './texture-tiling-detector.js';
import { GradientRampEditor } from './gradient-ramp-editor.js';
import { LightProbeVisualizer } from './light-probe-visualizer.js';
import { BRDFVisualizer } from './brdf-visualizer.js';
// Embedded default OpenPBR scene (simple sphere with material)
const EMBEDDED_USDA_SCENE = `#usda 1.0
@@ -4167,6 +4168,121 @@ function setupGUI() {
lightProbeFolder.add(lightProbeParams, 'exportReport').name('Export Report');
lightProbeFolder.close();
// BRDF Visualizer
const brdfVisualizerFolder = gui.addFolder('BRDF Visualizer');
const brdfVisualizerParams = {
enabled: false,
viewAngle: 0,
lightAngle: 45,
resolution: 64,
enable: function() {
if (!window.brdfVisualizer) {
window.brdfVisualizer = new BRDFVisualizer(renderer);
}
// Set material from selected object
if (selectedObject && selectedObject.material) {
window.brdfVisualizer.setMaterial(selectedObject.material);
}
window.brdfVisualizer.enable();
brdfVisualizerParams.enabled = true;
updateStatus('BRDF visualizer enabled', 'success');
},
disable: function() {
if (window.brdfVisualizer) {
window.brdfVisualizer.disable();
brdfVisualizerParams.enabled = false;
updateStatus('BRDF visualizer disabled', 'info');
}
},
updateMaterial: function() {
if (!window.brdfVisualizer) {
updateStatus('BRDF visualizer not enabled', 'error');
return;
}
if (!selectedObject || !selectedObject.material) {
updateStatus('No object with material selected', 'error');
return;
}
window.brdfVisualizer.setMaterial(selectedObject.material);
updateStatus('BRDF visualization updated', 'success');
},
analyzeAndLog: function() {
if (!window.brdfVisualizer) {
window.brdfVisualizer = new BRDFVisualizer(renderer);
}
if (selectedObject && selectedObject.material) {
window.brdfVisualizer.setMaterial(selectedObject.material);
}
window.brdfVisualizer.logAnalysis();
updateStatus('BRDF analysis logged to console', 'success');
},
exportReport: function() {
if (!window.brdfVisualizer) {
window.brdfVisualizer = new BRDFVisualizer(renderer);
}
if (selectedObject && selectedObject.material) {
window.brdfVisualizer.setMaterial(selectedObject.material);
}
const report = window.brdfVisualizer.generateReport();
const blob = new Blob([report], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'brdf_analysis.md';
a.click();
URL.revokeObjectURL(url);
updateStatus('BRDF analysis report exported', 'success');
}
};
brdfVisualizerFolder.add(brdfVisualizerParams, 'enabled').name('Enable Visualizer').onChange(value => {
if (value) {
brdfVisualizerParams.enable();
} else {
brdfVisualizerParams.disable();
}
});
brdfVisualizerFolder.add(brdfVisualizerParams, 'viewAngle', 0, 90, 1)
.name('View Angle (degrees)')
.onChange(value => {
if (window.brdfVisualizer) {
window.brdfVisualizer.setViewAngle(value);
}
});
brdfVisualizerFolder.add(brdfVisualizerParams, 'lightAngle', 0, 90, 1)
.name('Light Angle (degrees)')
.onChange(value => {
if (window.brdfVisualizer) {
window.brdfVisualizer.setLightAngle(value);
}
});
brdfVisualizerFolder.add(brdfVisualizerParams, 'resolution', [32, 64, 128])
.name('Resolution')
.onChange(value => {
if (window.brdfVisualizer) {
window.brdfVisualizer.resolution = value;
window.brdfVisualizer.updateVisualization();
}
});
brdfVisualizerFolder.add(brdfVisualizerParams, 'updateMaterial').name('Update from Selected Object');
brdfVisualizerFolder.add(brdfVisualizerParams, 'analyzeAndLog').name('Analyze and Log');
brdfVisualizerFolder.add(brdfVisualizerParams, 'exportReport').name('Export Report');
brdfVisualizerFolder.close();
// Split View Comparison System
const splitViewFolder = gui.addFolder('Split View Compare');
const splitViewParams = {
@@ -6523,6 +6639,7 @@ window.PBRTheoryGuide = PBRTheoryGuide;
window.TextureTilingDetector = TextureTilingDetector;
window.GradientRampEditor = GradientRampEditor;
window.LightProbeVisualizer = LightProbeVisualizer;
window.BRDFVisualizer = BRDFVisualizer;
window.REFERENCE_MATERIALS = REFERENCE_MATERIALS;
window.applyReferenceMaterial = applyReferenceMaterial;
window.getReferencesByCategory = getReferencesByCategory;