Skip to content

Instantly share code, notes, and snippets.

@GOROman
Created March 8, 2025 08:10
Show Gist options
  • Save GOROman/afeb0a20e9f7ceaec38159570d95a6a6 to your computer and use it in GitHub Desktop.
Save GOROman/afeb0a20e9f7ceaec38159570d95a6a6 to your computer and use it in GitHub Desktop.
ちょっとHな画像を描画する React
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;
@GOROman
Copy link
Author

GOROman commented Mar 8, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment