Add Pixel Inspector (Magnifying Glass) for detailed pixel analysis

- Created pixel-inspector.js with PixelInspector class
- Magnify area around cursor with configurable grid size

Features:
  - Real-time magnified view (3×3, 5×5, 7×7, 9×9 grids)
  - Pixelated rendering showing individual pixels
  - Center pixel highlighted in green
  - Crosshair cursor when enabled

Display Information:
  - Screen position (x, y)
  - RGB values (0-255)
  - Normalized RGB (0.0-1.0)
  - Hex color code
  - Material name and type
  - UV coordinates
  - PBR properties (base color, metalness, roughness)

Technical Implementation:
  - Reads pixels from framebuffer using gl.readPixels()
  - Raycasting for 3D object picking
  - Real-time updates on mouse move
  - Floating panel overlay (top-right corner)
  - Canvas with pixelated image-rendering

GUI Integration:
  - Enable/Disable toggle
  - Grid size dropdown (3×3 to 9×9)
  - Instructions shown in status bar

Use Cases:
  - Examine exact pixel colors
  - Compare neighboring pixels
  - Debug material blending
  - Identify discontinuities/artifacts
  - Inspect UV mapping at pixel level

Priority 3 feature (2/4 complete)
This commit is contained in:
Syoyo Fujita
2025-11-21 03:28:35 +09:00
parent dbe9cd038b
commit 1f572a7e78
3 changed files with 419 additions and 0 deletions

View File

@@ -1294,6 +1294,7 @@
<script type="module" src="ibl-contribution-analyzer.js"></script>
<script type="module" src="gbuffer-viewer.js"></script>
<script type="module" src="mipmap-visualizer.js"></script>
<script type="module" src="pixel-inspector.js"></script>
<!-- Main application script as module -->
<script type="module" src="materialx.js"></script>

View File

@@ -58,6 +58,7 @@ import {
import { IBLContributionAnalyzer } from './ibl-contribution-analyzer.js';
import { GBufferViewer } from './gbuffer-viewer.js';
import { MipMapVisualizer } from './mipmap-visualizer.js';
import { PixelInspector } from './pixel-inspector.js';
// Embedded default OpenPBR scene (simple sphere with material)
const EMBEDDED_USDA_SCENE = `#usda 1.0
@@ -3556,6 +3557,51 @@ function setupGUI() {
mipmapFolder.add(mipmapParams, 'exportReport').name('Export Report');
mipmapFolder.close();
// Pixel Inspector (Magnifying Glass)
const pixelInspectorFolder = gui.addFolder('Pixel Inspector');
const pixelInspectorParams = {
enabled: false,
gridSize: 5,
enable: function() {
if (!window.pixelInspector) {
window.pixelInspector = new PixelInspector(renderer, scene, camera, renderer.domElement);
}
window.pixelInspector.setGridSize(pixelInspectorParams.gridSize);
window.pixelInspector.enable();
pixelInspectorParams.enabled = true;
updateStatus('Pixel inspector enabled (hover over scene)', 'success');
},
disable: function() {
if (window.pixelInspector) {
window.pixelInspector.disable();
pixelInspectorParams.enabled = false;
updateStatus('Pixel inspector disabled', 'success');
}
}
};
pixelInspectorFolder.add(pixelInspectorParams, 'enabled').name('Enable Inspector').onChange(value => {
if (value) {
pixelInspectorParams.enable();
} else {
pixelInspectorParams.disable();
}
});
pixelInspectorFolder.add(pixelInspectorParams, 'gridSize', {
'3×3 Grid': 3,
'5×5 Grid (Default)': 5,
'7×7 Grid': 7,
'9×9 Grid': 9
}).name('Grid Size').onChange(size => {
pixelInspectorParams.gridSize = size;
if (window.pixelInspector && pixelInspectorParams.enabled) {
window.pixelInspector.setGridSize(size);
}
});
pixelInspectorFolder.close();
// Split View Comparison System
const splitViewFolder = gui.addFolder('Split View Compare');
const splitViewParams = {
@@ -5906,6 +5952,7 @@ window.MaterialComplexityAnalyzer = MaterialComplexityAnalyzer;
window.IBLContributionAnalyzer = IBLContributionAnalyzer;
window.GBufferViewer = GBufferViewer;
window.MipMapVisualizer = MipMapVisualizer;
window.PixelInspector = PixelInspector;
window.REFERENCE_MATERIALS = REFERENCE_MATERIALS;
window.applyReferenceMaterial = applyReferenceMaterial;
window.getReferencesByCategory = getReferencesByCategory;

371
web/js/pixel-inspector.js Normal file
View File

@@ -0,0 +1,371 @@
// Pixel Inspector (Magnifying Glass)
// Examine individual pixels in detail with RGB values and material properties
import * as THREE from 'three';
export class PixelInspector {
constructor(renderer, scene, camera, domElement) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
this.domElement = domElement;
this.enabled = false;
this.zoomLevel = 10; // 10x magnification
this.gridSize = 5; // 5x5 pixel grid
// Mouse position
this.mouseX = 0;
this.mouseY = 0;
// Pixel data
this.pixelData = null;
this.centerPixel = null;
// UI elements
this.inspectorPanel = null;
this.canvas = null;
this.ctx = null;
// Raycaster for picking
this.raycaster = new Raycaster();
this.mouse = new THREE.Vector2();
// Bind event handlers
this.onMouseMove = this.handleMouseMove.bind(this);
this.onMouseClick = this.handleMouseClick.bind(this);
}
// Enable pixel inspector
enable() {
this.enabled = true;
this.createUI();
this.domElement.addEventListener('mousemove', this.onMouseMove);
this.domElement.addEventListener('click', this.onMouseClick);
this.domElement.style.cursor = 'crosshair';
}
// Disable pixel inspector
disable() {
this.enabled = false;
this.destroyUI();
this.domElement.removeEventListener('mousemove', this.onMouseMove);
this.domElement.removeEventListener('click', this.onMouseClick);
this.domElement.style.cursor = 'default';
}
// Create UI panel
createUI() {
// Create panel
this.inspectorPanel = document.createElement('div');
this.inspectorPanel.id = 'pixel-inspector-panel';
this.inspectorPanel.style.cssText = `
position: absolute;
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: monospace;
font-size: 12px;
z-index: 1000;
pointer-events: none;
`;
// Create canvas for magnified view
this.canvas = document.createElement('canvas');
this.canvas.width = 150;
this.canvas.height = 150;
this.canvas.style.cssText = `
width: 150px;
height: 150px;
border: 1px solid #666;
image-rendering: pixelated;
margin-bottom: 10px;
`;
this.ctx = this.canvas.getContext('2d');
// Create info container
const infoContainer = document.createElement('div');
infoContainer.id = 'pixel-info';
infoContainer.innerHTML = '<p style="color: #888;">Hover over scene...</p>';
this.inspectorPanel.appendChild(this.canvas);
this.inspectorPanel.appendChild(infoContainer);
// Add to DOM
this.domElement.parentElement.appendChild(this.inspectorPanel);
}
// Destroy UI panel
destroyUI() {
if (this.inspectorPanel && this.inspectorPanel.parentElement) {
this.inspectorPanel.parentElement.removeChild(this.inspectorPanel);
}
this.inspectorPanel = null;
this.canvas = null;
this.ctx = null;
}
// Handle mouse move
handleMouseMove(event) {
if (!this.enabled) return;
const rect = this.domElement.getBoundingClientRect();
this.mouseX = event.clientX - rect.left;
this.mouseY = event.clientY - rect.top;
// Update immediately
this.updateInspector();
}
// Handle mouse click (lock inspection)
handleMouseClick(event) {
if (!this.enabled) return;
// Currently just updates - could add "lock" feature later
this.updateInspector();
}
// Update inspector display
updateInspector() {
// Read pixels around mouse position
const halfGrid = Math.floor(this.gridSize / 2);
const startX = Math.floor(this.mouseX) - halfGrid;
const startY = Math.floor(this.mouseY) - halfGrid;
// Read pixel data from framebuffer
const pixelBuffer = new Uint8Array(this.gridSize * this.gridSize * 4);
try {
this.renderer.readRenderTargetPixels(
this.renderer.getRenderTarget() || this.renderer,
startX,
this.renderer.domElement.height - startY - this.gridSize,
this.gridSize,
this.gridSize,
pixelBuffer
);
} catch (e) {
// Fallback: read from canvas if render target fails
const gl = this.renderer.getContext();
gl.readPixels(
startX,
this.renderer.domElement.height - startY - this.gridSize,
this.gridSize,
this.gridSize,
gl.RGBA,
gl.UNSIGNED_BYTE,
pixelBuffer
);
}
this.pixelData = pixelBuffer;
// Get center pixel
const centerIdx = (Math.floor(this.gridSize / 2) * this.gridSize + Math.floor(this.gridSize / 2)) * 4;
this.centerPixel = {
r: pixelBuffer[centerIdx],
g: pixelBuffer[centerIdx + 1],
b: pixelBuffer[centerIdx + 2],
a: pixelBuffer[centerIdx + 3]
};
// Raycast to get 3D info
this.mouse.x = (this.mouseX / this.domElement.width) * 2 - 1;
this.mouse.y = -(this.mouseY / this.domElement.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
let materialInfo = null;
if (intersects.length > 0) {
const hit = intersects[0];
if (hit.object.material) {
materialInfo = this.extractMaterialInfo(hit.object.material, hit.uv);
}
}
// Update UI
this.renderMagnifiedView();
this.updateInfoPanel(materialInfo);
}
// Extract material information
extractMaterialInfo(material, uv) {
const info = {
name: material.name || 'Unnamed',
type: material.type || 'Unknown',
uv: uv || new THREE.Vector2(0, 0)
};
// PBR properties
if (material.color) {
info.baseColor = [material.color.r, material.color.g, material.color.b];
}
if (material.metalness !== undefined) {
info.metalness = material.metalness;
}
if (material.roughness !== undefined) {
info.roughness = material.roughness;
}
if (material.emissive) {
info.emissive = [material.emissive.r, material.emissive.g, material.emissive.b];
}
if (material.emissiveIntensity !== undefined) {
info.emissiveIntensity = material.emissiveIntensity;
}
return info;
}
// Render magnified pixel view
renderMagnifiedView() {
if (!this.ctx || !this.pixelData) return;
const pixelSize = this.canvas.width / this.gridSize;
// Clear canvas
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// Draw pixels
for (let y = 0; y < this.gridSize; y++) {
for (let x = 0; x < this.gridSize; x++) {
const idx = ((this.gridSize - 1 - y) * this.gridSize + x) * 4; // Flip Y
const r = this.pixelData[idx];
const g = this.pixelData[idx + 1];
const b = this.pixelData[idx + 2];
this.ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
this.ctx.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
}
}
// Draw grid lines
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.lineWidth = 1;
for (let i = 0; i <= this.gridSize; i++) {
this.ctx.beginPath();
this.ctx.moveTo(i * pixelSize, 0);
this.ctx.lineTo(i * pixelSize, this.canvas.height);
this.ctx.stroke();
this.ctx.beginPath();
this.ctx.moveTo(0, i * pixelSize);
this.ctx.lineTo(this.canvas.width, i * pixelSize);
this.ctx.stroke();
}
// Highlight center pixel
const centerX = Math.floor(this.gridSize / 2);
const centerY = Math.floor(this.gridSize / 2);
this.ctx.strokeStyle = '#4CAF50';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(centerX * pixelSize, centerY * pixelSize, pixelSize, pixelSize);
}
// Update info panel
updateInfoPanel(materialInfo) {
const infoDiv = document.getElementById('pixel-info');
if (!infoDiv || !this.centerPixel) return;
let html = '<div style="line-height: 1.6;">';
// Position
html += `<p><strong>Position:</strong> (${Math.floor(this.mouseX)}, ${Math.floor(this.mouseY)})</p>`;
// Center pixel RGB
html += '<p><strong>Center Pixel RGB:</strong></p>';
html += `<p style="margin-left: 10px;">`;
html += `R: ${this.centerPixel.r} <span style="color: #ff6b6b;">●</span><br>`;
html += `G: ${this.centerPixel.g} <span style="color: #51cf66;">●</span><br>`;
html += `B: ${this.centerPixel.b} <span style="color: #339af0;">●</span><br>`;
html += `</p>`;
// Normalized values
const nr = (this.centerPixel.r / 255).toFixed(3);
const ng = (this.centerPixel.g / 255).toFixed(3);
const nb = (this.centerPixel.b / 255).toFixed(3);
html += `<p><strong>Normalized:</strong> (${nr}, ${ng}, ${nb})</p>`;
// Hex color
const hex = '#' +
this.centerPixel.r.toString(16).padStart(2, '0') +
this.centerPixel.g.toString(16).padStart(2, '0') +
this.centerPixel.b.toString(16).padStart(2, '0');
html += `<p><strong>Hex:</strong> ${hex.toUpperCase()}</p>`;
// Material info if available
if (materialInfo) {
html += '<hr style="border-color: #444; margin: 10px 0;">';
html += `<p><strong>Material:</strong> ${materialInfo.name}</p>`;
html += `<p><strong>Type:</strong> ${materialInfo.type}</p>`;
if (materialInfo.uv) {
html += `<p><strong>UV:</strong> (${materialInfo.uv.x.toFixed(3)}, ${materialInfo.uv.y.toFixed(3)})</p>`;
}
if (materialInfo.baseColor) {
const bc = materialInfo.baseColor;
html += `<p><strong>Base Color:</strong> (${bc[0].toFixed(3)}, ${bc[1].toFixed(3)}, ${bc[2].toFixed(3)})</p>`;
}
if (materialInfo.metalness !== undefined) {
html += `<p><strong>Metalness:</strong> ${materialInfo.metalness.toFixed(2)}</p>`;
}
if (materialInfo.roughness !== undefined) {
html += `<p><strong>Roughness:</strong> ${materialInfo.roughness.toFixed(2)}</p>`;
}
}
html += '</div>';
infoDiv.innerHTML = html;
}
// Set zoom level
setZoomLevel(level) {
this.zoomLevel = level;
}
// Set grid size
setGridSize(size) {
this.gridSize = size;
if (this.canvas) {
// Recreate canvas with new size
const parent = this.canvas.parentElement;
parent.removeChild(this.canvas);
this.canvas = document.createElement('canvas');
this.canvas.width = 150;
this.canvas.height = 150;
this.canvas.style.cssText = `
width: 150px;
height: 150px;
border: 1px solid #666;
image-rendering: pixelated;
margin-bottom: 10px;
`;
this.ctx = this.canvas.getContext('2d');
parent.insertBefore(this.canvas, parent.firstChild);
}
}
// Get state
getState() {
return {
enabled: this.enabled,
zoomLevel: this.zoomLevel,
gridSize: this.gridSize,
centerPixel: this.centerPixel
};
}
}
// Make class globally accessible
if (typeof window !== 'undefined') {
window.PixelInspector = PixelInspector;
}