The user wants a single self-contained HTML file using Three.js that renders a 3D first-person perspective interactive/animated endless Gothic hallway. Let me break down the requirements:
-
Dark stone vaulted arch ceiling
-
Stone pillars
-
Glossy reflective black-and-white checkered marble floor
-
Arched stained glass side windows with colorful mosaic patterns (diamond grids, rose wheels, Voronoi cells)
-
Bright volumetric light shafts/god rays casting vibrant colored light projections onto the floor
-
Subtle dark vignetting
-
Smooth camera movement forward down the corridor
-
Endless (procedurally generated as camera moves)
This is a complex scene. Let me plan the implementation:
-
Use Three.js from CDN
-
Procedurally generate the hallway segments as the camera moves forward
-
The floor will be a checkered pattern using a shader or texture
-
The ceiling will be vaulted arches using geometry
-
Pillars along the sides
-
Stained glass windows with procedural textures (diamond grids, rose wheels, Voronoi cells)
-
Volumetric light shafts using transparent cone/cylinder geometries with additive blending
-
Vignetting via post-processing or a shader overlay
-
Camera moves forward smoothly
Let me think about the endless aspect: I'll create hallway segments and recycle them as the camera passes them.
For the stained glass, I'll create canvas textures with the different patterns.
For volumetric god rays, I'll use transparent geometries with colored materials and additive blending, plus colored point lights.
Let me code this up comprehensively.
I'll structure it as:
-
Scene setup with fog for atmosphere
-
Hallway segment class that creates a chunk of hallway
-
Segment recycling system
-
Stained glass texture generation via canvas
-
Floor with checkered pattern (using a shader material for reflectivity)
-
Vaulted ceiling geometry
-
Pillars with Gothic styling
-
Light shafts as volumetric cones
-
Post-processing vignette via a full-screen quad with a vignette shader
-
Smooth forward movement with slight sway
Let me write this out carefully. I'll use Three.js r128+ from CDN.
For the vaulted ceiling, I can create it using a parametric surface or by constructing it from segments. A simple approach: create a series of arches that form a vault.
Actually, let me think about the geometry more carefully:
-
Floor: checkered plane
-
Walls: stone-textured planes on sides
-
Ceiling: vaulted arch - I can create this by extruding an arch shape along the hallway, or by creating individual arch segments
-
Pillars: cylinders with some Gothic detail (maybe with capitals)
-
Windows: arched openings in the walls with stained glass planes
For the endless hallway, I'll create segments of fixed length and move them behind the camera as it passes.
Let me code this step by step.
For the stained glass patterns:
-
Diamond grid - simple diamond shapes with colors
-
Rose wheel - circular pattern like a rose window
-
Voronoi cells - compute Voronoi diagram on canvas
For volumetric light shafts: I'll create cone geometries pointing down from the windows with additive blending and colored materials. I'll also add colored spotlights/point lights.
For the glossy reflective floor, I can use MeshPhysicalMaterial with high metalness/roughness settings, or use a custom shader. Actually, for true reflections I'd need an environment map or render target. A simpler approach: use a reflective material and add an environment map generated from the scene, or just make it look glossy with high shininess. Let me use MeshPhysicalMaterial with clearcoat for the marble look, and maybe add a simple reflection using a render target if needed. Actually, for a single file, let me use a custom shader that does a simple reflection approximation, or just use MeshStandardMaterial with high metalness.
Actually, let me use a custom floor shader that gives a glossy checkered look with fake reflections. Or I can use PMREMGenerator for environment mapping. Let me keep it simpler: use MeshPhysicalMaterial with metalness 0.9, roughness 0.1, and the checkered texture as the base color.
Let me write the code now. I'll make it comprehensive and visually impressive.
For the vignette, I'll add a post-processing pass using a custom shader material on a fullscreen quad rendered on top.
Let me structure the code:
<!DOCTYPE html>
<html>
<head>
<style>body { margin: 0; overflow: hidden; background: #000; }</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"
}
}
</script>
<script type="module">
// ... all the code
</script>
</body>
</html>
Let me write the full implementation now. I need to be careful about:
-
Segment management for the endless hallway
-
Stained glass texture generation
-
Volumetric light rendering
-
Performance (don't create too many geometries)
I'll create a HallwaySegment class that builds one segment of the hallway, and maintain a pool of segments positioned ahead of the camera.
For the vaulted ceiling, I'll create a series of intersecting arches. A Gothic rib vault can be approximated by creating arch frames.
Let me think about the segment length - maybe 10 units per segment, with 6-8 segments visible ahead.
For the stained glass windows, each segment will have 2 windows (left and right). I'll cycle through the different patterns.
Let me code this up properly now.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Endless Gothic Hallway</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; }
canvas { display: block; }
#vignette {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 10;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.7) 100%);
}
#info {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
color: rgba(200,180,150,0.6); font-family: Georgia, serif; font-size: 14px;
pointer-events: none; z-index: 11; text-align: center;
text-shadow: 0 0 10px rgba(0,0,0,0.8);
}
</style>
</head>
<body>
<div id="vignette"></div>
<div id="info">Move mouse to look around · The endless corridor goes on forever</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// ── Globals ──
const SEGMENT_LENGTH = 12;
const HALLWAY_WIDTH = 8;
const HALLWAY_HEIGHT = 9;
const NUM_SEGMENTS = 10;
const CAMERA_SPEED = 3.5;
const SEGMENT_POOL = [];
const LIGHT_SHAFTS = [];
let camera, scene, renderer, clock;
let mouseX = 0, mouseY = 0, targetLookX = 0, targetLookY = 0;
let cameraYOffset = 1.7;
let time = 0;
// ── Texture Generators ──
function createStoneTexture() {
const c = document.createElement('canvas');
c.width = 512; c.height = 512;
const ctx = c.getContext('2d');
ctx.fillStyle = '#2a2825';
ctx.fillRect(0, 0, 512, 512);
for (let i = 0; i < 6000; i++) {
const x = Math.random() * 512, y = Math.random() * 512;
const b = 30 + Math.random() * 25;
ctx.fillStyle = `rgb(${b},${b-2},${b-4})`;
ctx.fillRect(x, y, 1 + Math.random() * 3, 1 + Math.random() * 3);
}
for (let i = 0; i < 800; i++) {
const x = Math.random() * 512, y = Math.random() * 512;
ctx.fillStyle = `rgba(0,0,0,${Math.random() * 0.3})`;
ctx.beginPath();
ctx.arc(x, y, Math.random() * 4, 0, Math.PI * 2);
ctx.fill();
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
return tex;
}
function createCheckeredTexture() {
const c = document.createElement('canvas');
c.width = 512; c.height = 512;
const ctx = c.getContext('2d');
const tileSize = 64;
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
const dark = (x + y) % 2 === 0;
const r = dark ? 15 + Math.random() * 5 : 210 + Math.random() * 20;
const g = dark ? 14 + Math.random() * 5 : 208 + Math.random() * 20;
const b = dark ? 16 + Math.random() * 5 : 215 + Math.random() * 20;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(x * tileSize, y * tileSize, tileSize, tileSize);
// marble veining
if (dark) {
ctx.strokeStyle = `rgba(40,38,35,${0.3 + Math.random() * 0.3})`;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(x * tileSize, y * tileSize + Math.random() * tileSize);
ctx.bezierCurveTo(
x * tileSize + tileSize * 0.3, y * tileSize + Math.random() * tileSize,
x * tileSize + tileSize * 0.7, y * tileSize + Math.random() * tileSize,
x * tileSize + tileSize, y * tileSize + Math.random() * tileSize
);
ctx.stroke();
}
}
}
const tex = new THREE.CanvasTexture(c);
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1, 1);
return tex;
}
function createStainedGlassTexture(patternType, seed) {
const c = document.createElement('canvas');
c.width = 512; c.height = 512;
const ctx = c.getContext('2d');
const rng = mulberry32(seed);
const palette = [
'#c41e3a', '#1e3ac4', '#c4a01e', '#1ec43a', '#8b1ec4',
'#c41e8b', '#1ec4c4', '#c46e1e', '#4a0e4e', '#0e4e4a',
'#e8d44d', '#d44d8b', '#4dd4e8', '#e84d4d', '#4de88b',
'#7b2d8b', '#2d7b8b', '#8b7b2d', '#2d8b4d', '#8b2d4d'
];
// Arch mask: Gothic pointed arch
function isInsideArch(x, y) {
const cx = 256, cy = 380, w = 200, h = 300;
if (x < cx - w / 2 || x > cx + w / 2) return false;
if (y > cy) return false;
if (y > cy - h) return false;
const dx = Math.abs(x - cx);
const archY = cy - h + h * 0.6;
if (y > archY) {
// lower rectangular part
return true;
}
// pointed arch: two intersecting circles
const r = w * 1.1;
const leftCenter = { x: cx - w * 0.35, y: archY + r - h * 0.55 };
const rightCenter = { x: cx + w * 0.35, y: archY + r - h * 0.55 };
const d1 = Math.sqrt((x - leftCenter.x) ** 2 + (y - leftCenter.y) ** 2);
const d2 = Math.sqrt((x - rightCenter.x) ** 2 + (y - rightCenter.y) ** 2);
return d1 < r && d2 < r;
}
ctx.fillStyle = '#0a0810';
ctx.fillRect(0, 0, 512, 512);
const color = () => palette[Math.floor(rng() * palette.length)];
const leadColor = '#1a1a2e';
if (patternType === 'diamond') {
// Diamond grid pattern
const size = 50;
ctx.save();
ctx.beginPath();
for (let x = 0; x <= 512; x += 4) {
for (let y = 0; y <= 512; y += 4) {
if (isInsideArch(x, y)) ctx.rect(x, y, 4, 4);
}
}
ctx.clip();
for (let y = -size; y < 512 + size; y += size) {
for (let x = -size; x < 512 + size; x += size) {
const cx = x + size / 2, cy = y + size / 2;
const s = size / 2 - 2;
ctx.fillStyle = color();
ctx.beginPath();
ctx.moveTo(cx, cy - s);
ctx.lineTo(cx + s, cy);
ctx.lineTo(cx, cy + s);
ctx.lineTo(cx - s, cy);
ctx.closePath();
ctx.fill();
// inner diamond
ctx.fillStyle = color();
ctx.beginPath();
ctx.moveTo(cx, cy - s * 0.5);
ctx.lineTo(cx + s * 0.5, cy);
ctx.lineTo(cx, cy + s * 0.5);
ctx.lineTo(cx - s * 0.5, cy);
ctx.closePath();
ctx.fill();
}
}
// lead lines
ctx.strokeStyle = leadColor;
ctx.lineWidth = 3;
for (let y = -size; y < 512 + size; y += size) {
for (let x = -size; x < 512 + size; x += size) {
const cx = x + size / 2, cy = y + size / 2;
const s = size / 2 - 2;
ctx.beginPath();
ctx.moveTo(cx, cy - s);
ctx.lineTo(cx + s, cy);
ctx.lineTo(cx, cy + s);
ctx.lineTo(cx - s, cy);
ctx.closePath();
ctx.stroke();
}
}
ctx.restore();
} else if (patternType === 'rose') {
// Rose window pattern
const cx = 256, cy = 280, maxR = 220;
ctx.save();
ctx.beginPath();
for (let x = 0; x <= 512; x += 4) {
for (let y = 0; y <= 512; y += 4) {
if (isInsideArch(x, y)) ctx.rect(x, y, 4, 4);
}
}
ctx.clip();
const rings = 4;
const petals = 8;
for (let ring = rings; ring >= 0; ring--) {
const r = maxR * (ring / rings);
const nextR = maxR * ((ring + 1) / rings);
for (let p = 0; p < petals; p++) {
const a1 = (p / petals) * Math.PI * 2;
const a2 = ((p + 1) / petals) * Math.PI * 2;
const midA = (a1 + a2) / 2;
ctx.fillStyle = color();
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a1) * r, cy + Math.sin(a1) * r);
ctx.lineTo(cx + Math.cos(midA) * nextR, cy + Math.sin(midA) * nextR);
ctx.lineTo(cx + Math.cos(a2) * r, cy + Math.sin(a2) * r);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = leadColor;
ctx.lineWidth = 2.5;
ctx.stroke();
}
// center circle of ring
for (let p = 0; p < petals; p++) {
const a1 = (p / petals) * Math.PI * 2;
const a2 = ((p + 0.5) / petals) * Math.PI * 2;
ctx.fillStyle = color();
ctx.beginPath();
ctx.arc(cx + Math.cos((a1 + a2) / 2) * (r + nextR) * 0.5,
cy + Math.sin((a1 + a2) / 2) * (r + nextR) * 0.5,
(nextR - r) * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = leadColor;
ctx.lineWidth = 2;
ctx.stroke();
}
}
// center boss
ctx.fillStyle = color();
ctx.beginPath();
ctx.arc(cx, cy, maxR * 0.12, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = leadColor;
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
} else if (patternType === 'voronoi') {
// Voronoi cells
const points = [];
for (let i = 0; i < 30; i++) {
points.push({
x: 80 + rng() * 352,
y: 40 + rng() * 432,
color: color()
});
}
// Simple pixel-based Voronoi
const imgData = ctx.createImageData(512, 512);
const step = 2;
for (let y = 0; y < 512; y += step) {
for (let x = 0; x < 512; x += step) {
if (!isInsideArch(x, y)) continue;
let minD = Infinity, closest = 0, secondD = Infinity;
for (let i = 0; i < points.length; i++) {
const d = (x - points[i].x) ** 2 + (y - points[i].y) ** 2;
if (d < minD) {
secondD = minD;
minD = d;
closest = i;
} else if (d < secondD) {
secondD = d;
}
}
const edgeDist = secondD - minD;
const isEdge = edgeDist < 900;
const hex = isEdge ? '#1a1a2e' : points[closest].color;
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
for (let dy = 0; dy < step && y + dy < 512; dy++) {
for (let dx = 0; dx < step && x + dx < 512; dx++) {
const idx = ((y + dy) * 512 + (x + dx)) * 4;
imgData.data[idx] = r;
imgData.data[idx + 1] = g;
imgData.data[idx + 2] = b;
imgData.data[idx + 3] = 255;
}
}
}
}
ctx.putImageData(imgData, 0, 0);
// Draw lead lines between cells
ctx.strokeStyle = leadColor;
ctx.lineWidth = 2.5;
for (let i = 0; i < points.length; i++) {
for (let j = i + 1; j < points.length; j++) {
const d = Math.sqrt((points[i].x - points[j].x) ** 2 + (points[i].y - points[j].y) ** 2);
if (d < 200) {
// perpendicular bisector clipped to arch
const mx = (points[i].x + points[j].x) / 2;
const my = (points[i].y + points[j].y) / 2;
const dx = points[j].x - points[i].x;
const dy = points[j].y - points[i].y;
const len = 150;
ctx.beginPath();
ctx.moveTo(mx - (dy / d) * len, my - (dx / d) * len);
ctx.lineTo(mx + (dy / d) * len, my + (dx / d) * len);
ctx.stroke();
}
}
}
}
// Dark stone frame around arch
ctx.save();
ctx.strokeStyle = '#1e1c18';
ctx.lineWidth = 14;
ctx.beginPath();
for (let x = 0; x <= 512; x += 2) {
for (let y = 0; y <= 512; y += 2) {
const inside = isInsideArch(x, y);
const outside = !isInsideArch(x + (inside ? 1 : -1), y);
if (inside && outside) {
ctx.moveTo(x, y);
}
}
}
// Draw arch outline properly
const archCx = 256, archW = 200, archBase = 380, archTop = 80;
const r = archW * 1.1;
ctx.beginPath();
ctx.moveTo(archCx - archW / 2, archBase);
ctx.lineTo(archCx - archW / 2, archBase - archW * 0.7);
ctx.arc(archCx - archW * 0.35, archBase - archW * 0.7 + r - (archBase - archTop - archW * 0.7), r, Math.PI * 0.8, Math.PI * 1.15, true);
ctx.arc(archCx + archW * 0.35, archBase - archW * 0.7 + r - (archBase - archTop - archW * 0.7), r, Math.PI * 1.15, Math.PI * 0.2, true);
ctx.lineTo(archCx + archW / 2, archBase);
ctx.closePath();
ctx.stroke();
ctx.restore();
const tex = new THREE.CanvasTexture(c);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
}
function mulberry32(a) {
return function () {
a |= 0; a = a + 0x6D2B79F5 | 0;
let t = Math.imul(a ^ a >>> 15, 1 | a);
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
return ((t ^ t >>> 14) >>> 0) / 4294967296;
};
}
// ── Geometry Builders ──
function createVaultedCeilingGeometry() {
const group = new THREE.Group();
const stoneTex = createStoneTexture();
const stoneMat = new THREE.MeshStandardMaterial({
map: stoneTex, roughness: 0.95, metalness: 0.05, color: 0x3a3530
});
// Create rib vault: two intersecting barrel vaults
// Main longitudinal vault
const segments = 20;
const shape = new THREE.Shape();
shape.moveTo(-HALLWAY_WIDTH / 2, 0);
shape.quadraticCurveTo(0, HALLWAY_HEIGHT * 1.1, HALLWAY_WIDTH / 2, 0);
const curve = new THREE.LineCurve3(
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, SEGMENT_LENGTH)
);
const extrudeSettings = {
steps: 1, bevelEnabled: false, extrudePath: curve
};
// Actually let's build the vault manually with custom geometry for better control
// Cross vault: build from triangles
const geo = new THREE.BufferGeometry();
const vertices = [];
const normals = [];
const uvs = [];
const vaultSegments = 16;
const archSegments = 12;
const halfW = HALLWAY_WIDTH / 2;
const archHeight = HALLWAY_HEIGHT * 1.05;
// Generate points on the vault surface (pointed arch profile extruded along Z)
function getArchPoint(t, z) {
// Pointed arch: two circular arcs
const x = (t - 0.5) * HALLWAY_WIDTH;
const r = halfW * 1.3;
let y;
if (Math.abs(t - 0.5) < 0.5) {
const dx = Math.abs(x);
const centerOffset = halfW * 0.4;
const dist = Math.sqrt((dx - centerOffset) ** 2 + 0);
y = archHeight * 0.55 + Math.sqrt(Math.max(0, r * r - (dx - centerOffset) ** 2));
}
y = Math.max(0, y);
return new THREE.Vector3(x, y, z);
}
for (let i = 0; i <= vaultSegments; i++) {
const z = (i / vaultSegments) * SEGMENT_LENGTH;
for (let j = 0; j <= archSegments; j++) {
const t = j / archSegments;
const p = getArchPoint(t, z);
vertices.push(p.x, p.y, p.z);
const n = new THREE.Vector3(p.x, p.y - archHeight * 0.5, 0).normalize();
normals.push(n.x, n.y, n.z);
uvs.push(t, i / vaultSegments);
}
}
const indices = [];
for (let i = 0; i < vaultSegments; i++) {
for (let j = 0; j < archSegments; j++) {
const a = i * (archSegments + 1) + j;
const b = a + 1;
const c = a + archSegments + 1;
const d = c + 1;
indices.push(a, c, b);
indices.push(b, c, d);
}
}
geo.setIndex(indices);
geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geo.computeVertexNormals();
const vault = new THREE.Mesh(geo, stoneMat);
vault.rotation.x = Math.PI; // flip to be ceiling
vault.position.y = HALLWAY_HEIGHT;
group.add(vault);
// Rib arches (decorative ribs)
const ribMat = new THREE.MeshStandardMaterial({
color: 0x2a2520, roughness: 0.9, metalness: 0.1
});
for (let z = 0; z <= SEGMENT_LENGTH; z += SEGMENT_LENGTH / 2) {
const ribCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(-halfW, HALLWAY_HEIGHT, z),
new THREE.Vector3(-halfW * 0.7, HALLWAY_HEIGHT + archHeight * 0.4, z),
new THREE.Vector3(0, HALLWAY_HEIGHT + archHeight * 0.75, z),
new THREE.Vector3(halfW * 0.7, HALLWAY_HEIGHT + archHeight * 0.4, z),
new THREE.Vector3(halfW, HALLWAY_HEIGHT, z)
]);
const ribGeo = new THREE.TubeGeometry(ribCurve, 20, 0.15, 8, false);
group.add(new THREE.Mesh(ribGeo, ribMat));
}
// Diagonal ribs
const diagRibCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(-halfW, HALLWAY_HEIGHT, 0),
new THREE.Vector3(-halfW * 0.5, HALLWAY_HEIGHT + archHeight * 0.5, SEGMENT_LENGTH * 0.25),
new THREE.Vector3(0, HALLWAY_HEIGHT + archHeight * 0.78, SEGMENT_LENGTH * 0.5),
new THREE.Vector3(halfW * 0.5, HALLWAY_HEIGHT + archHeight * 0.5, SEGMENT_LENGTH * 0.75),
new THREE.Vector3(halfW, HALLWAY_HEIGHT, SEGMENT_LENGTH)
]);
group.add(new THREE.Mesh(new THREE.TubeGeometry(diagRibCurve, 30, 0.12, 8, false), ribMat));
const diagRibCurve2 = diagRibCurve.clone();
const mirrorMatrix = new THREE.Matrix4().makeScale(-1, 1, 1);
diagRibCurve2.applyMatrix4(mirrorMatrix);
group.add(new THREE.Mesh(new THREE.TubeGeometry(diagRibCurve2, 30, 0.12, 8, false), ribMat));
return group;
}
function createPillar(x, z) {
const group = new THREE.Group();
const stoneMat = new THREE.MeshStandardMaterial({
color: 0x3a3530, roughness: 0.9, metalness: 0.05
});
// Main shaft
const shaftGeo = new THREE.CylinderGeometry(0.35, 0.4, HALLWAY_HEIGHT, 12);
group.add(new THREE.Mesh(shaftGeo, stoneMat));
// Fluting
for (let i = 0; i < 12; i++) {
const angle = (i / 12) * Math.PI * 2;
const fluteGeo = new THREE.BoxGeometry(0.04, HALLWAY_HEIGHT * 0.9, 0.04);
const flute = new THREE.Mesh(fluteGeo, new THREE.MeshStandardMaterial({
color: 0x252018, roughness: 0.95
}));
flute.position.set(Math.cos(angle) * 0.32, 0, Math.sin(angle) * 0.32);
flute.rotation.y = angle;
group.add(flute);
}
// Capital (Gothic style)
const capGeo = new THREE.CylinderGeometry(0.55, 0.35, 0.6, 12);
const cap = new THREE.Mesh(capGeo, stoneMat);
cap.position.y = HALLWAY_HEIGHT / 2 + 0.3;
group.add(cap);
// Capital molding
const moldGeo = new THREE.TorusGeometry(0.5, 0.08, 8, 12);
const mold = new THREE.Mesh(moldGeo, stoneMat);
mold.position.y = HALLWAY_HEIGHT / 2 + 0.6;
mold.rotation.x = Math.PI / 2;
group.add(mold);
// Base
const baseGeo = new THREE.CylinderGeometry(0.55, 0.6, 0.4, 12);
const base = new THREE.Mesh(baseGeo, stoneMat);
base.position.y = -HALLWAY_HEIGHT / 2 + 0.2;
group.add(base);
const baseStepGeo = new THREE.CylinderGeometry(0.65, 0.55, 0.15, 12);
const baseStep = new THREE.Mesh(baseStepGeo, stoneMat);
baseStep.position.y = -HALLWAY_HEIGHT / 2 + 0.075;
group.add(baseStep);
group.position.set(x, HALLWAY_HEIGHT / 2, z);
return group;
}
function createWindow(z, side, patternType, seed) {
const group = new THREE.Group();
const x = side * (HALLWAY_WIDTH / 2 + 0.01);
// Stained glass pane
const glassTex = createStainedGlassTexture(patternType, seed);
const glassGeo = new THREE.PlaneGeometry(3, 5.5, 1, 1);
const glassMat = new THREE.MeshBasicMaterial({
map: glassTex, transparent: true, opacity: 0.95,
side: THREE.DoubleSide
});
const glass = new THREE.Mesh(glassGeo, glassMat);
glass.position.set(x, HALLWAY_HEIGHT * 0.75, z + SEGMENT_LENGTH / 2);
glass.rotation.y = Math.PI / 2 * side;
group.add(glass);
// Emissive glow behind glass
const glowMat = new THREE.MeshBasicMaterial({
color: 0xffeedd, transparent: true, opacity: 0.4,
side: THREE.DoubleSide
});
const glow = new THREE.Mesh(glassGeo.clone(), glowMat);
glow.position.set(x - side * 0.1, HALLWAY_HEIGHT * 0.75, z + SEGMENT_LENGTH / 2);
glow.rotation.y = Math.PI / 2 * side;
group.add(glow);
// Window arch frame
const frameMat = new THREE.MeshStandardMaterial({
color: 0x2a2520, roughness: 0.9, metalness: 0.1
});
const archCurve = new THREE.CatmullRomCurve3([
new THREE.Vector3(0, 0, -1.5),
new THREE.Vector3(0, 2, -1.2),
new THREE.Vector3(0, 3.5, -0.5),
new THREE.Vector3(0, 4.5, 0.5),
new THREE.Vector3(0, 3.5, 1.5),
new THREE.Vector3(0, 2, 1.2),
new THREE.Vector3(0, 0, 1.5),
]);
// Actually let's make a proper pointed arch frame
const frameShape = new THREE.Shape();
const fw = 1.5, fh = 5.5;
frameShape.moveTo(-fw, -fh / 2);
frameShape.lineTo(-fw, fh / 2 - fw);
// pointed arch
const r = fw * 1.2;
const archStartY = fh / 2 - fw;
const centerX = -fw * 0.3;
frameShape.absarc(centerX, archStartY + r - fw * 0.5, r, Math.PI * 0.75, Math.PI * 1.15, false);
frameShape.absarc(fw * 0.3, archStartY + r - fw * 0.5, r, Math.PI * 1.15, Math.PI * 0.25, false);
frameShape.lineTo(fw, fh / 2 - fw);
frameShape.lineTo(fw, -fh / 2);
frameShape.lineTo(-fw, -fh / 2);
const holeShape = new THREE.Shape();
const hw = 1.3, hh = 5.2;
holeShape.moveTo(-hw, -hh / 2);
holeShape.lineTo(-hw, hh / 2 - hw);
const hr = hw * 1.2;
const hArchStartY = hh / 2 - hw;
holeShape.absarc(-hw * 0.3, hArchStartY + hr - hw * 0.5, hr, Math.PI * 0.75, Math.PI * 1.15, false);
holeShape.absarc(hw * 0.3, hArchStartY + hr - hw * 0.5, hr, Math.PI * 1.15, Math.PI * 0.25, false);
holeShape.lineTo(hw, hh / 2 - hw);
holeShape.lineTo(hw, -hh / 2);
holeShape.lineTo(-hw, -hh / 2);
frameShape.holes.push(holeShape);
const frameGeo = new THREE.ExtrudeGeometry(frameShape, { depth: 0.3, bevelEnabled: false });
const frame = new THREE.Mesh(frameGeo, frameMat);
frame.position.set(x - side * 0.15, HALLWAY_HEIGHT * 0.75, z + SEGMENT_LENGTH / 2);
frame.rotation.y = Math.PI / 2 * side;
group.add(frame);
return group;
}
function createLightShaft(z, side, color) {
const group = new THREE.Group();
const x = side * (HALLWAY_WIDTH / 2 - 0.5);
// Volumetric cone (god ray)
const coneGeo = new THREE.ConeGeometry(3.5, 10, 32, 1, true);
const coneMat = new THREE.MeshBasicMaterial({
color: color, transparent: true, opacity: 0.06,
blending: THREE.AdditiveBlending, depthWrite: false,
side: THREE.DoubleSide
});
const cone = new THREE.Mesh(coneGeo, coneMat);
cone.position.set(x, HALLWAY_HEIGHT * 0.5, z + SEGMENT_LENGTH / 2);
cone.rotation.z = Math.PI;
cone.rotation.x = 0.3 * side;
group.add(cone);
// Inner brighter cone
const innerConeGeo = new THREE.ConeGeometry(1.5, 9, 16, 1, true);
const innerConeMat = new THREE.MeshBasicMaterial({
color: color, transparent: true, opacity: 0.1,
blending: THREE.AdditiveBlending, depthWrite: false,
side: THREE.DoubleSide
});
const innerCone = new THREE.Mesh(innerConeGeo, innerConeMat);
innerCone.position.copy(cone.position);
innerCone.rotation.copy(cone.rotation);
group.add(innerCone);
// Dust particles in the light
const particleCount = 80;
const particleGeo = new THREE.BufferGeometry();
const positions = new Float32Array(particleCount * 3);
const sizes = new Float32Array(particleCount);
for (let i = 0; i < particleCount; i++) {
const t = Math.random();
const r = Math.random() * 2.5 * (1 - t * 0.5);
const angle = Math.random() * Math.PI * 2;
positions[i * 3] = x + Math.cos(angle) * r + Math.sin(time) * 0.1;
positions[i * 3 + 1] = HALLWAY_HEIGHT * 0.75 - t * 8;
positions[i * 3 + 2] = z + SEGMENT_LENGTH / 2 + Math.sin(angle) * r;
sizes[i] = 2 + Math.random() * 4;
}
particleGeo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
particleGeo.setAttribute('size', new THREE.Float32BufferAttribute(sizes, 1));
const particleMat = new THREE.PointsMaterial({
color: color, size: 0.08, transparent: true, opacity: 0.6,
blending: THREE.AdditiveBlending, depthWrite: false,
sizeAttenuation: true
});
const particles = new THREE.Points(particleGeo, particleMat);
group.add(particles);
group.userData.particles = particles;
group.userData.particleGeo = particleGeo;
// Colored light
const light = new THREE.PointLight(color, 8, 15, 2);
light.position.set(x, HALLWAY_HEIGHT * 0.75, z + SEGMENT_LENGTH / 2);
group.add(light);
// Colored spotlight on floor
const spotLight = new THREE.SpotLight(color, 15, 20, Math.PI / 4, 0.5, 2);
spotLight.position.set(x, HALLWAY_HEIGHT * 0.85, z + SEGMENT_LENGTH / 2);
spotLight.target.position.set(x, 0, z + SEGMENT_LENGTH / 2);
group.add(spotLight);
group.add(spotLight.target);
// Colored light pool on floor (bright patch)
const poolGeo = new THREE.PlaneGeometry(5, 5);
const poolMat = new THREE.MeshBasicMaterial({
color: color, transparent: true, opacity: 0.15,
blending: THREE.AdditiveBlending, depthWrite: false,
side: THREE.DoubleSide
});
const pool = new THREE.Mesh(poolGeo, poolMat);
pool.rotation.x = -Math.PI / 2;
pool.position.set(x, 0.01, z + SEGMENT_LENGTH / 2);
group.add(pool);
group.userData.color = color;
group.userData.baseOpacity = 0.06;
return group;
}
function createSegment(index) {
const group = new THREE.Group();
const z = 0;
const stoneTex = createStoneTexture();
// Side walls
const wallMat = new THREE.MeshStandardMaterial({
map: stoneTex, roughness: 0.95, metalness: 0.05, color: 0x3a3530
});
for (let side = -1; side <= 1; side += 2) {
const wallGeo = new THREE.PlaneGeometry(SEGMENT_LENGTH, HALLWAY_HEIGHT, 1, 1);
const wall = new THREE.Mesh(wallGeo, wallMat);
wall.position.set(side * HALLWAY_WIDTH / 2, HALLWAY_HEIGHT / 2, z + SEGMENT_LENGTH / 2);
wall.rotation.y = -Math.PI / 2 * side;
group.add(wall);
// Wall buttresses
const buttressGeo = new THREE.BoxGeometry(0.8, HALLWAY_HEIGHT, 1.2);
const buttress = new THREE.Mesh(buttressGeo, wallMat);
buttress.position.set(side * (HALLWAY_WIDTH / 2 + 0.4), HALLWAY_HEIGHT / 2, z + SEGMENT_LENGTH / 2);
group.add(buttress);
}
// Back wall
const backWallGeo = new THREE.PlaneGeometry(HALLWAY_WIDTH, HALLWAY_HEIGHT);
const backWall = new THREE.Mesh(backWallGeo, wallMat);
backWall.position.set(0, HALLWAY_HEIGHT / 2, z + SEGMENT_LENGTH);
group.add(backWall);
// Pillars
for (let side = -1; side <= 1; side += 2) {
group.add(createPillar(side * (HALLWAY_WIDTH / 2 - 0.7), z));
group.add(createPillar(side * (HALLWAY_WIDTH / 2 - 0.7), z + SEGMENT_LENGTH));
}
// Vaulted ceiling
group.add(createVaultedCeilingGeometry());
// Stained glass windows
const patterns = ['diamond', 'rose', 'voronoi'];
const patternType = patterns[index % patterns.length];
const seed = index * 137 + 42;
for (let side = -1; side <= 1; side += 2) {
group.add(createWindow(z, side, patternType, seed + side));
}
// Light shafts
const lightColors = [0xff6644, 0x44aaff, 0xffcc44, 0x88ff44, 0xff44aa, 0x44ffcc];
const lightColor = lightColors[index % lightColors.length];
for (let side = -1; side <= 1; side += 2) {
const shaft = createLightShaft(z, side, lightColor);
group.add(shaft);
LIGHT_SHAFTS.push(shaft);
}
// Decorative floor molding along walls
const moldingMat = new THREE.MeshStandardMaterial({
color: 0x4a4035, roughness: 0.8, metalness: 0.15
});
for (let side = -1; side <= 1; side += 2) {
const moldingGeo = new THREE.BoxGeometry(SEGMENT_LENGTH, 0.15, 0.3);
const molding = new THREE.Mesh(moldingGeo, moldingMat);
molding.position.set(side * (HALLWAY_WIDTH / 2 - 0.15), 0.075, z + SEGMENT_LENGTH / 2);
group.add(molding);
}
// Gothic wall niches between pillars
for (let side = -1; side <= 1; side += 2) {
const nicheGeo = new THREE.SphereGeometry(0.8, 16, 16, 0, Math.PI, 0, Math.PI / 2);
const niche = new THREE.Mesh(nicheGeo, wallMat);
niche.position.set(side * (HALLWAY_WIDTH / 2 - 0.79), 1.5, z + SEGMENT_LENGTH / 2);
niche.rotation.y = Math.PI / 2 * side;
group.add(niche);
// Torch/brazier in niche
const brazierMat = new THREE.MeshStandardMaterial({
color: 0x5a4a30, roughness: 0.7, metalness: 0.3
});
const brazierGeo = new THREE.CylinderGeometry(0.15, 0.2, 0.3, 8);
const brazier = new THREE.Mesh(brazierGeo, brazierMat);
brazier.position.set(side * (HALLWAY_WIDTH / 2 - 0.79), 1.1, z + SEGMENT_LENGTH / 2);
group.add(brazier);
// Torch flame light
const flameLight = new THREE.PointLight(0xff8844, 2, 8, 2);
flameLight.position.set(side * (HALLWAY_WIDTH / 2 - 0.79), 1.5, z + SEGMENT_LENGTH / 2);
group.add(flameLight);
// Flame glow
const flameGlowGeo = new THREE.SphereGeometry(0.12, 8, 8);
const flameGlowMat = new THREE.MeshBasicMaterial({
color: 0xffaa44, transparent: true, opacity: 0.8
});
const flameGlow = new THREE.Mesh(flameGlowGeo, flameGlowMat);
flameGlow.position.copy(flameLight.position);
flameGlow.position.y += 0.15;
group.add(flameGlow);
group.userData.flames = group.userData.flames || [];
group.userData.flames.push({ light: flameLight, glow: flameGlow, baseY: flameLight.position.y });
}
return group;
}
// ── Floor ──
function createFloor() {
const checkeredTex = createCheckeredTexture();
checkeredTex.repeat.set(4, 4);
const floorGeo = new THREE.PlaneGeometry(HALLWAY_WIDTH, 2000, 1, 1);
const floorMat = new THREE.MeshPhysicalMaterial({
map: checkeredTex,
roughness: 0.15,
metalness: 0.85,
clearcoat: 1.0,
clearcoatRoughness: 0.1,
reflectivity: 1,
color: 0xffffff,
envMapIntensity: 0.5
});
const floor = new THREE.Mesh(floorGeo, floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
return floor;
}
// ── Init ──
function init() {
clock = new THREE.Clock();
scene = new THREE.Scene();
scene.background = new THREE.Color(0x050408);
scene.fog = new THREE.FogExp2(0x050408, 0.035);
camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, cameraYOffset, 0);
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.8;
document.body.appendChild(renderer.domElement);
// Environment map for reflections (simple procedural)
const pmremGenerator = new THREE.PMREMGenerator(renderer);
const envScene = new THREE.Scene();
envScene.background = new THREE.Color(0x111118);
const envLight1 = new THREE.DirectionalLight(0xffeedd, 1);
envLight1.position.set(5, 10, 5);
envScene.add(envLight1);
const envLight2 = new THREE.DirectionalLight(0x4488ff, 0.5);
envLight2.position.set(-5, 5, -5);
envScene.add(envLight2);
const envRT = pmremGenerator.fromScene(envScene);
scene.environment = envRT.texture;
pmremGenerator.dispose();
// Floor
const floor = createFloor();
scene.add(floor);
// Ambient light (very dim)
const ambient = new THREE.AmbientLight(0x1a1520, 0.3);
scene.add(ambient);
// Initialize segment pool
for (let i = 0; i < NUM_SEGMENTS; i++) {
const segment = createSegment(i);
segment.position.z = i * SEGMENT_LENGTH;
scene.add(segment);
SEGMENT_POOL.push({ mesh: segment, index: i });
}
// Events
window.addEventListener('resize', onResize);
window.addEventListener('mousemove', onMouseMove);
}
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function onMouseMove(e) {
mouseX = (e.clientX / window.innerWidth - 0.5) * 2;
mouseY = (e.clientY / window.innerHeight - 0.5) * 2;
}
// ── Animation ──
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05);
time += delta;
// Move camera forward
camera.position.z += CAMERA_SPEED * delta;
// Camera sway (breathing/head movement)
camera.position.x = Math.sin(time * 0.5) * 0.15;
camera.position.y = cameraYOffset + Math.sin(time * 0.8) * 0.05;
// Look direction from mouse
targetLookX += (mouseX - targetLookX) * 3 * delta;
targetLookY += (mouseY * 0.5 - targetLookY) * 3 * delta;
const lookTarget = new THREE.Vector3(
targetLookX * 3,
-targetLookY * 2 + 1.5,
camera.position.z + 10
);
camera.lookAt(lookTarget);
// Recycle segments
const cameraZ = camera.position.z;
for (const seg of SEGMENT_POOL) {
if (seg.mesh.position.z + SEGMENT_LENGTH < cameraZ - SEGMENT_LENGTH) {
// Find the farthest segment ahead
let maxZ = -Infinity;
let farthest = null;
for (const other of SEGMENT_POOL) {
if (other.mesh.position.z > maxZ) {
maxZ = other.mesh.position.z;
farthest = other;
}
}
// Move this segment to just beyond the farthest
seg.index++;
seg.mesh.position.z = farthest.mesh.position.z + SEGMENT_LENGTH;
// Rebuild with new index for new patterns
scene.remove(seg.mesh);
seg.mesh = createSegment(seg.index);
seg.mesh.position.z = farthest.mesh.position.z + SEGMENT_LENGTH;
scene.add(seg.mesh);
}
}
// Animate torch flames
for (const seg of SEGMENT_POOL) {
if (seg.mesh.userData.flames) {
for (const flame of seg.mesh.userData.flames) {
const flicker = Math.sin(time * 12 + flame.baseY * 100) * 0.3 +
Math.sin(time * 17 + flame.baseY * 50) * 0.2;
flame.light.intensity = 2 + flicker;
flame.glow.scale.setScalar(0.8 + flicker * 0.5);
flame.glow.position.y = flame.baseY + 0.15 + flicker * 0.05;
}
}
}
// Animate light shafts (subtle pulsing)
for (const shaft of LIGHT_SHAFTS) {
const pulse = Math.sin(time * 1.5 + shaft.position.z * 0.1) * 0.15 + 1;
for (const child of shaft.children) {
if (child.material && child.material.opacity !== undefined && child.material.opacity < 0.5) {
child.material.opacity = (child === shaft.children[0] ? shaft.userData.baseOpacity : 0.1) * pulse;
}
}
// Animate dust particles
if (shaft.userData.particleGeo) {
const pos = shaft.userData.particleGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
pos.array[i * 3 + 1] -= delta * 0.5; // drift down
pos.array[i * 3] += Math.sin(time * 2 + i) * delta * 0.3;
pos.array[i * 3 + 2] += Math.cos(time * 1.5 + i) * delta * 0.2;
// Reset if fallen too far
if (pos.array[i * 3 + 1] < -2) {
pos.array[i * 3 + 1] = HALLWAY_HEIGHT * 0.75;
}
}
pos.needsUpdate = true;
}
}
renderer.render(scene, camera);
}
init();
animate();
</script>
</body>
</html>
This creates a fully self-contained endless Gothic hallway with:
-
Vaulted ribbed ceiling
— pointed arch geometry with decorative stone ribs crossing diagonally
-
Fluted stone pillars
with Gothic capitals and stepped bases lining both sides
-
Glossy checkered marble floor
using
MeshPhysicalMaterial
with clearcoat, high metalness, and an environment map for reflections
-
Three stained glass window patterns
cycling per segment: diamond grids, rose wheels (concentric petal rings), and Voronoi cells with lead-line borders, all shaped as pointed Gothic arches
-
Volumetric god rays
— additive-blended cones with inner bright cores, drifting dust particles, colored point/spot lights, and glowing light pools on the floor
-
Flickering torch braziers
in wall niches between pillars
-
Dark vignetting
via CSS radial gradient overlay
-
Smooth endless movement
— camera drifts forward with breathing sway; hallway segments recycle behind you with new pattern seeds
-
Mouse look
— move your mouse to steer the view; fog fades the distant corridor into darkness