Created
March 8, 2025 08:10
-
-
Save GOROman/afeb0a20e9f7ceaec38159570d95a6a6 to your computer and use it in GitHub Desktop.
ちょっとHな画像を描画する React
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useRef, useState, useEffect } from 'react'; | |
const Raytracer = () => { | |
const canvasRef = useRef(null); | |
const [isRendering, setIsRendering] = useState(false); | |
const [progress, setProgress] = useState(0); | |
const renderingRef = useRef(false); | |
const requestRef = useRef(null); | |
useEffect(() => { | |
// Canvas setup on mount | |
const canvas = canvasRef.current; | |
if (canvas) { | |
const ctx = canvas.getContext('2d'); | |
ctx.fillStyle = 'black'; | |
ctx.fillRect(0, 0, canvas.width, canvas.height); | |
} | |
// Cleanup on unmount | |
return () => { | |
renderingRef.current = false; | |
if (requestRef.current) { | |
cancelAnimationFrame(requestRef.current); | |
} | |
}; | |
}, []); | |
const startRaytracing = () => { | |
if (isRendering) return; | |
setIsRendering(true); | |
setProgress(0); | |
renderingRef.current = true; | |
const canvas = canvasRef.current; | |
const ctx = canvas.getContext('2d'); | |
const width = canvas.width; | |
const height = canvas.height; | |
// Create an ImageData to draw pixel by pixel | |
const imageData = ctx.createImageData(width, height); | |
const data = imageData.data; | |
// Clear canvas | |
ctx.fillStyle = 'black'; | |
ctx.fillRect(0, 0, width, height); | |
// Raytracing logic | |
let pixelsProcessed = 0; | |
let currentX = 0; | |
let currentY = 0; | |
let lastUpdateTime = Date.now(); | |
// Vector operations | |
const Vec3 = { | |
create: (x, y, z) => ({ x, y, z }), | |
add: (v1, v2) => ({ x: v1.x + v2.x, y: v1.y + v2.y, z: v1.z + v2.z }), | |
sub: (v1, v2) => ({ x: v1.x - v2.x, y: v1.y - v2.y, z: v1.z - v2.z }), | |
mul: (v, s) => ({ x: v.x * s, y: v.y * s, z: v.z * s }), | |
div: (v, s) => ({ x: v.x / s, y: v.y / s, z: v.z / s }), | |
dot: (v1, v2) => v1.x * v2.x + v1.y * v2.y + v1.z * v2.z, | |
normalize: (v) => { | |
const length = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); | |
if (length < 0.00001) return { x: 0, y: 0, z: 0 }; | |
return { x: v.x / length, y: v.y / length, z: v.z / length }; | |
}, | |
reflect: (v, n) => { | |
const dot2 = 2 * Vec3.dot(v, n); | |
return Vec3.sub(v, Vec3.mul(n, dot2)); | |
}, | |
length: (v) => Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) | |
}; | |
// Scene setup | |
const sphere = { | |
center: Vec3.create(0, 1, 0), | |
radius: 1, | |
color: Vec3.create(1, 0.2, 0.2), | |
specular: 0.9, | |
reflective: 0.7, | |
type: 'sphere' | |
}; | |
// H形状のモデルを追加 | |
const hModel = { | |
type: 'h-model', | |
position: Vec3.create(0, 1, -1), // 球体の手前に配置(カメラと球体の間) | |
color: Vec3.create(1, 0.4, 0.8), // ピンク色 | |
specular: 0.7, | |
reflective: 0.5, | |
// H文字の各部の寸法 | |
dimensions: { | |
height: 0.8, // 小さくする | |
width: 0.6, // 小さくする | |
thickness: 0.15, // 薄くする | |
crossbarHeight: 0.15, | |
crossbarY: 0 // 中心からの相対位置 | |
} | |
}; | |
const floor = { | |
y: 0, | |
color1: Vec3.create(0.9, 0.9, 0.9), // Light color - brighter | |
color2: Vec3.create(0.2, 0.2, 0.3), // Dark color - more contrast | |
tileSize: 1, | |
specular: 0.3, // More specular for shinier floor | |
reflective: 0.5, // More reflective for dramatic reflections | |
type: 'floor' | |
}; | |
const lights = [ | |
{ | |
position: Vec3.create(5, 5, 5), | |
intensity: 1, | |
color: Vec3.create(1, 1, 1) | |
}, | |
{ | |
position: Vec3.create(-5, 3, -3), | |
intensity: 0.8, | |
color: Vec3.create(0.2, 0.8, 1) | |
}, | |
{ | |
position: Vec3.create(3, 2, -5), | |
intensity: 0.7, | |
color: Vec3.create(1, 0.8, 0.2) | |
} | |
]; | |
const camera = { | |
position: Vec3.create(0, 1.8, -4), // 少し近づける | |
lookAt: Vec3.create(0, 0.8, 0), // Looking slightly lower | |
up: Vec3.create(0, 1, 0), | |
fov: 70 * Math.PI / 180, // 視野角を広げる | |
aspectRatio: width / height | |
}; | |
// Compute camera parameters | |
const lookDir = Vec3.normalize(Vec3.sub(camera.lookAt, camera.position)); | |
const right = Vec3.normalize({ | |
x: lookDir.z, | |
y: 0, | |
z: -lookDir.x | |
}); | |
const up = Vec3.normalize(Vec3.sub(camera.up, Vec3.mul(lookDir, Vec3.dot(camera.up, lookDir)))); | |
// Ray-sphere intersection | |
const intersectSphere = (origin, dir, sphere) => { | |
const oc = Vec3.sub(origin, sphere.center); | |
const a = Vec3.dot(dir, dir); | |
const b = 2 * Vec3.dot(oc, dir); | |
const c = Vec3.dot(oc, oc) - sphere.radius * sphere.radius; | |
const discriminant = b * b - 4 * a * c; | |
if (discriminant < 0) { | |
return null; | |
} | |
const t1 = (-b - Math.sqrt(discriminant)) / (2 * a); | |
const t2 = (-b + Math.sqrt(discriminant)) / (2 * a); | |
if (t1 > 0.001) { | |
const point = Vec3.add(origin, Vec3.mul(dir, t1)); | |
const normal = Vec3.normalize(Vec3.sub(point, sphere.center)); | |
return { t: t1, point, normal, object: sphere }; | |
} | |
if (t2 > 0.001) { | |
const point = Vec3.add(origin, Vec3.mul(dir, t2)); | |
const normal = Vec3.normalize(Vec3.sub(point, sphere.center)); | |
return { t: t2, point, normal, object: sphere }; | |
} | |
return null; | |
}; | |
// Ray-floor intersection | |
const intersectFloor = (origin, dir, floor) => { | |
if (Math.abs(dir.y) < 0.001) { | |
return null; | |
} | |
const t = (floor.y - origin.y) / dir.y; | |
if (t > 0.001) { | |
const point = Vec3.add(origin, Vec3.mul(dir, t)); | |
const normal = Vec3.create(0, 1, 0); | |
// Calculate checkerboard pattern | |
const x = Math.floor(point.x / floor.tileSize); | |
const z = Math.floor(point.z / floor.tileSize); | |
const isWhite = (x + z) % 2 === 0; | |
return { | |
t, | |
point, | |
normal, | |
object: floor, | |
color: isWhite ? floor.color1 : floor.color2 | |
}; | |
} | |
return null; | |
}; | |
// Ray-box intersection for H-model parts | |
const intersectBox = (origin, dir, min, max) => { | |
const tMin = Vec3.create( | |
(min.x - origin.x) / (Math.abs(dir.x) < 0.0001 ? 0.0001 : dir.x), | |
(min.y - origin.y) / (Math.abs(dir.y) < 0.0001 ? 0.0001 : dir.y), | |
(min.z - origin.z) / (Math.abs(dir.z) < 0.0001 ? 0.0001 : dir.z) | |
); | |
const tMax = Vec3.create( | |
(max.x - origin.x) / (Math.abs(dir.x) < 0.0001 ? 0.0001 : dir.x), | |
(max.y - origin.y) / (Math.abs(dir.y) < 0.0001 ? 0.0001 : dir.y), | |
(max.z - origin.z) / (Math.abs(dir.z) < 0.0001 ? 0.0001 : dir.z) | |
); | |
const t1 = Math.max( | |
Math.min(tMin.x, tMax.x), | |
Math.min(tMin.y, tMax.y), | |
Math.min(tMin.z, tMax.z) | |
); | |
const t2 = Math.min( | |
Math.max(tMin.x, tMax.x), | |
Math.max(tMin.y, tMax.y), | |
Math.max(tMin.z, tMax.z) | |
); | |
// No intersection if t2 < 0 or t1 > t2 | |
if (t2 < 0 || t1 > t2) { | |
return null; | |
} | |
// Use t1 if it's positive, otherwise use t2 | |
const t = t1 > 0.001 ? t1 : t2; | |
if (t < 0.001) { | |
return null; | |
} | |
const point = Vec3.add(origin, Vec3.mul(dir, t)); | |
// Calculate normal based on which face was hit | |
let normal; | |
const epsilon = 0.0001; | |
if (Math.abs(point.x - min.x) < epsilon) normal = Vec3.create(-1, 0, 0); | |
else if (Math.abs(point.x - max.x) < epsilon) normal = Vec3.create(1, 0, 0); | |
else if (Math.abs(point.y - min.y) < epsilon) normal = Vec3.create(0, -1, 0); | |
else if (Math.abs(point.y - max.y) < epsilon) normal = Vec3.create(0, 1, 0); | |
else if (Math.abs(point.z - min.z) < epsilon) normal = Vec3.create(0, 0, -1); | |
else normal = Vec3.create(0, 0, 1); | |
return { t, point, normal }; | |
}; | |
// H形状のモデルとの交差判定 | |
const intersectHModel = (origin, dir, hModel) => { | |
const pos = hModel.position; | |
const d = hModel.dimensions; | |
// Hの左の縦棒 | |
const leftPillar = { | |
min: Vec3.create( | |
pos.x - d.width/2, | |
pos.y - d.height/2, | |
pos.z - d.thickness/2 | |
), | |
max: Vec3.create( | |
pos.x - d.width/2 + d.thickness, | |
pos.y + d.height/2, | |
pos.z + d.thickness/2 | |
) | |
}; | |
// Hの右の縦棒 | |
const rightPillar = { | |
min: Vec3.create( | |
pos.x + d.width/2 - d.thickness, | |
pos.y - d.height/2, | |
pos.z - d.thickness/2 | |
), | |
max: Vec3.create( | |
pos.x + d.width/2, | |
pos.y + d.height/2, | |
pos.z + d.thickness/2 | |
) | |
}; | |
// Hの横棒 | |
const crossbar = { | |
min: Vec3.create( | |
pos.x - d.width/2 + d.thickness, | |
pos.y + d.crossbarY - d.crossbarHeight/2, | |
pos.z - d.thickness/2 | |
), | |
max: Vec3.create( | |
pos.x + d.width/2 - d.thickness, | |
pos.y + d.crossbarY + d.crossbarHeight/2, | |
pos.z + d.thickness/2 | |
) | |
}; | |
// 各パーツとの交差判定 | |
const leftHit = intersectBox(origin, dir, leftPillar.min, leftPillar.max); | |
const rightHit = intersectBox(origin, dir, rightPillar.min, rightPillar.max); | |
const crossHit = intersectBox(origin, dir, crossbar.min, crossbar.max); | |
// 最も近い交点を探す | |
let closest = null; | |
let minDist = Infinity; | |
if (leftHit && leftHit.t < minDist) { | |
closest = leftHit; | |
minDist = leftHit.t; | |
} | |
if (rightHit && rightHit.t < minDist) { | |
closest = rightHit; | |
minDist = rightHit.t; | |
} | |
if (crossHit && crossHit.t < minDist) { | |
closest = crossHit; | |
minDist = crossHit.t; | |
} | |
if (closest) { | |
closest.object = hModel; | |
return closest; | |
} | |
return null; | |
}; | |
// Check all intersections | |
const intersectScene = (origin, dir) => { | |
// 球体との交差判定 | |
const sphereHit = intersectSphere(origin, dir, sphere); | |
// H形状モデルとの交差判定 | |
const hModelHit = intersectHModel(origin, dir, hModel); | |
// 床との交差判定 | |
const floorHit = intersectFloor(origin, dir, floor); | |
// 最も近い交点を返す | |
let closest = null; | |
let minDist = Infinity; | |
if (sphereHit && sphereHit.t < minDist) { | |
closest = sphereHit; | |
minDist = sphereHit.t; | |
} | |
if (hModelHit && hModelHit.t < minDist) { | |
closest = hModelHit; | |
minDist = hModelHit.t; | |
} | |
if (floorHit && floorHit.t < minDist) { | |
closest = floorHit; | |
minDist = floorHit.t; | |
} | |
return closest; | |
}; | |
// Calculate shadows for a specific light | |
const isInShadow = (point, lightDir, lightDist) => { | |
const shadowHit = intersectScene(point, lightDir); | |
return shadowHit !== null && shadowHit.t < lightDist; | |
}; | |
// Calculate lighting | |
const calculateLighting = (hit, viewDir) => { | |
let totalDiffuse = 0.05; // Ambient light | |
let totalSpecular = 0; | |
// Move the point slightly away from the surface to avoid self-intersection | |
const shadowOrigin = Vec3.add(hit.point, Vec3.mul(hit.normal, 0.001)); | |
// Process each light | |
for (const light of lights) { | |
const lightVec = Vec3.sub(light.position, hit.point); | |
const lightDir = Vec3.normalize(lightVec); | |
const distanceToLight = Vec3.length(lightVec); | |
// Check if the point is in shadow for this light | |
if (!isInShadow(shadowOrigin, lightDir, distanceToLight)) { | |
// Diffuse component | |
const diffuse = Math.max(0, Vec3.dot(hit.normal, lightDir)); | |
// Specular component - higher exponent for more focused highlights | |
const reflectDir = Vec3.reflect(Vec3.mul(lightDir, -1), hit.normal); | |
const specPower = hit.object.type === 'h-model' ? 16 : 64; // Different specular for letter vs. sphere | |
const specular = Math.pow(Math.max(0, Vec3.dot(reflectDir, viewDir)), specPower) * hit.object.specular; | |
// Combine lighting components with light color | |
const intensity = light.intensity / (1 + 0.05 * distanceToLight * distanceToLight); | |
totalDiffuse += diffuse * intensity * light.color.x; | |
totalDiffuse += diffuse * intensity * light.color.y; | |
totalDiffuse += diffuse * intensity * light.color.z; | |
totalSpecular += specular * intensity * light.color.x; | |
totalSpecular += specular * intensity * light.color.y; | |
totalSpecular += specular * intensity * light.color.z; | |
} | |
} | |
return { | |
diffuse: totalDiffuse / 3, // Average the RGB components | |
specular: totalSpecular / 3 | |
}; | |
}; | |
// Trace rays | |
const traceRay = (origin, dir, depth = 0) => { | |
if (depth > 4) return Vec3.create(0, 0, 0); | |
const hit = intersectScene(origin, dir); | |
if (!hit) { | |
return Vec3.create(0.7, 0.8, 1.0); // Sky color | |
} | |
// Get object color | |
let color; | |
if (hit.object.type === 'h-model') { | |
// Hモデルの色を使用 | |
color = hit.object.color; | |
} else { | |
color = hit.color || hit.object.color; | |
} | |
// Calculate lighting | |
const viewDir = Vec3.mul(dir, -1); | |
const lighting = calculateLighting(hit, viewDir); | |
// Calculate reflection | |
let reflection = Vec3.create(0, 0, 0); | |
if (hit.object.reflective > 0 && depth < 4) { | |
const reflectDir = Vec3.reflect(dir, hit.normal); | |
const reflectOrigin = Vec3.add(hit.point, Vec3.mul(hit.normal, 0.001)); | |
reflection = traceRay(reflectOrigin, reflectDir, depth + 1); | |
} | |
// Combine colors with more emphasis on reflections for more dramatic effect | |
const reflectFactor = hit.object.reflective; | |
const finalColor = { | |
x: color.x * lighting.diffuse + lighting.specular + reflection.x * reflectFactor, | |
y: color.y * lighting.diffuse + lighting.specular + reflection.y * reflectFactor, | |
z: color.z * lighting.diffuse + lighting.specular + reflection.z * reflectFactor | |
}; | |
return finalColor; | |
}; | |
// シングルピクセルレンダリング関数 | |
const renderPixel = () => { | |
if (!renderingRef.current) { | |
return; | |
} | |
// 現在のフレームで処理するピクセル数(1/10の速度) | |
const pixelsPerFrame = 50; // 500から50に減らして1/10の速度に | |
// 一度に複数のピクセルを処理 | |
for (let i = 0; i < pixelsPerFrame; i++) { | |
// 次のピクセルの色を計算 | |
const screenX = (2 * (currentX + 0.5) / width - 1) * Math.tan(camera.fov / 2) * camera.aspectRatio; | |
const screenY = -(2 * (currentY + 0.5) / height - 1) * Math.tan(camera.fov / 2); | |
const rayDir = Vec3.normalize({ | |
x: screenX * right.x + screenY * up.x + lookDir.x, | |
y: screenX * right.y + screenY * up.y + lookDir.y, | |
z: screenX * right.z + screenY * up.z + lookDir.z | |
}); | |
// レイトレーシングで色を計算 | |
const color = traceRay(camera.position, rayDir); | |
// 色の値を0-255の範囲に変換 | |
const r = Math.min(255, Math.max(0, Math.floor(color.x * 255))); | |
const g = Math.min(255, Math.max(0, Math.floor(color.y * 255))); | |
const b = Math.min(255, Math.max(0, Math.floor(color.z * 255))); | |
// ピクセルデータに色を設定 | |
const idx = (currentY * width + currentX) * 4; | |
data[idx] = r; | |
data[idx + 1] = g; | |
data[idx + 2] = b; | |
data[idx + 3] = 255; | |
// 次のピクセル位置に移動 | |
currentX++; | |
if (currentX >= width) { | |
currentX = 0; | |
currentY++; | |
// 行が終わるたびにキャンバスを更新 | |
if (currentY % 5 === 0) { | |
ctx.putImageData(imageData, 0, 0); | |
} | |
} | |
// 処理済みピクセル数を更新 | |
pixelsProcessed++; | |
// 最終行まで処理したらレンダリング終了 | |
if (currentY >= height) { | |
ctx.putImageData(imageData, 0, 0); | |
setIsRendering(false); | |
renderingRef.current = false; | |
return; | |
} | |
} | |
// キャンバスを更新 | |
ctx.putImageData(imageData, 0, 0); | |
// 進捗状況を更新 | |
const newProgress = Math.floor((pixelsProcessed / (width * height)) * 100); | |
if (newProgress !== progress) { | |
setProgress(newProgress); | |
} | |
// 次のフレームでさらに処理 | |
requestRef.current = requestAnimationFrame(renderPixel); | |
}; | |
// レンダリング開始 | |
renderingRef.current = true; | |
requestRef.current = requestAnimationFrame(renderPixel); | |
}; | |
return ( | |
<div className="flex flex-col items-center gap-4 p-4"> | |
<h1 className="text-2xl font-bold">ちょっとHな画像</h1> | |
<div className="border border-gray-300 rounded"> | |
<canvas | |
ref={canvasRef} | |
width={400} | |
height={400} | |
className="bg-black" | |
/> | |
</div> | |
<div className="flex items-center gap-4"> | |
<button | |
onClick={startRaytracing} | |
disabled={isRendering} | |
className={`px-4 py-2 rounded text-white ${ | |
isRendering ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600' | |
}`} | |
> | |
{isRendering ? '描画中...' : '描画開始'} | |
</button> | |
{isRendering && ( | |
<div className="flex items-center gap-2"> | |
<div className="w-64 h-4 bg-gray-200 rounded-full"> | |
<div | |
className="h-full bg-blue-500 rounded-full" | |
style={{ width: `${progress}%` }} | |
></div> | |
</div> | |
<span>{progress}%</span> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
}; | |
export default Raytracer; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
You can try it.
https://claude.site/artifacts/7f526ef8-412a-4e29-8125-219d64bf5b4b