⭐ Built in the Agent OS — get it inside the AI Profit BoardroomJoin AIPB →
...
``` JS code structure: 1. Get canvas and gl context. 2. Compile shaders. 3. Create program. 4. Create full-screen quad buffer and VAO. 5. Create accumulation textures and framebuffers. 6. Set up camera and event listeners. 7. Render loop. Compile shader helper: ```js function compileShader(gl, type, source) { const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)); gl.deleteShader(shader); return null; } return shader; } ``` Create program: ```js const vs = compileShader(gl, gl.VERTEX_SHADER, vertexShaderSource); const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource); const program = gl.createProgram(); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); ``` VAO: ```js const vao = gl.createVertexArray(); gl.bindVertexArray(vao); const posBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW); const posLoc = gl.getAttribLocation(program, 'a_position'); gl.enableVertexAttribArray(posLoc); gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); ``` Accumulation textures: ```js const tex0 = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, tex0); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, w, h, 0, gl.RGBA, gl.HALF_FLOAT, null); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); ... const fb0 = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, fb0); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex0, 0); ``` Ping-pong: two textures/fbos. In the render loop, bind the current framebuffer, set uniforms, draw full-screen quad. Then swap. Wait, if we directly blend in the path tracing shader, we only need one framebuffer (render to tex0), and use tex1 as the previous frame input. Then swap tex0 and tex1. Specifically: - Frame N: render to fb0 (tex0), sampling tex1 as previous frame. - Swap: next frame, render to fb1 (tex1), sampling tex0. In the first frame (frameIndex=0), the content of the previous texture doesn't matter, but the shader will still sample it. We can set u_frame=0 and ignore prev. Uniforms: - u_resolution - u_frame - u_camPos - u_camDir - u_camUp - u_prevFrame Note: u_prevFrame should be bound to the texture unit of the previous frame's texture. In the render loop: ```js let frameIndex = 0; function render() { const writeFB = frameIndex % 2 === 0 ? fb0 : fb1; const readTex = frameIndex % 2 === 0 ? tex1 : tex0; gl.bindFramebuffer(gl.FRAMEBUFFER, writeFB); gl.viewport(0, 0, canvas.width, canvas.height); gl.useProgram(program); gl.bindVertexArray(vao); gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, readTex); gl.uniform1i(prevFrameLoc, 0); gl.uniform2f(resLoc, canvas.width, canvas.height); gl.uniform1i(frameLoc, frameIndex); gl.uniform3f(camPosLoc, ...); gl.uniform3f(camDirLoc, ...); gl.uniform3f(camUpLoc, ...); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); // Now display writeFB's texture to canvas? Or just bind framebuffer null and draw again? } ``` Wait, if we render to a framebuffer, the canvas won't show anything. We need to render the result to the canvas. There are two options: 1. In the same draw call, render to the framebuffer, then in another draw call, render the texture to the canvas using a simple copy shader. 2. Or, directly set gl.bindFramebuffer(gl.FRAMEBUFFER, null) and use the path tracing shader to render to the canvas, but then we can't read the previous frame's accumulation. Actually, option 1 is necessary. We need a display pass. So, two shaders: path tracing shader (renders to accumulation texture) and display shader (copies texture to screen with tone mapping). Alternatively, we can do tone mapping in the path tracing shader and store the tone-mapped result in the accumulation texture. But accumulation should be done in linear space before tone mapping; otherwise, averaging tone-mapped values is incorrect. So, we need two passes: Pass 1: path trace, accumulate linear HDR, write to accumulation texture. Pass 2: read accumulation texture, tone map, gamma correct, write to screen. Pass 2 can use a simple shader. This means we need two programs and two fragment shaders. Program 1: path tracing -> accumulation texture. Program 2: copy/tonemap -> canvas. In the path tracing shader, output to fragColor the accumulated linear color. In the display shader, sample the accumulation texture and apply tone mapping. Both passes use the same full-screen quad. For performance, the display pass is very cheap. Now, the display shader: ```glsl #version 300 es precision highp float; in vec2 v_uv; out vec4 fragColor; uniform sampler2D u_texture; void main() { vec3 col = texture(u_texture, v_uv).rgb; // tone mapping col = col / (col + vec3(1.0)); col = pow(col, vec3(1.0/2.2)); fragColor = vec4(col, 1.0); } ``` For the tone mapping function, I can use a slightly better one, such as ACES approximation, but Reinhard is simpler. Now, considering anti-aliasing and jitter. In the path tracing shader, use gl_FragCoord.xy plus random offset to compute uv. But in the path tracing shader, we need to know the pixel coordinate. We can pass gl_FragCoord via gl_FragCoord built-in. In WebGL 2, gl_FragCoord is available. Ray generation: ```glsl vec2 jitter = vec2(rand(), rand()); vec2 uv = (gl_FragCoord.xy + jitter) / u_resolution; ``` Camera setup. I'll use a spherical coordinate system. Camera target is the center of the box (0, 0.5, 0). Radius, theta (azimuth), phi (polar). Camera position = target + radius * (sin(phi)*cos(theta), cos(phi), sin(phi)*sin(theta)). Camera direction = normalize(target - position). Up vector is (0,1,0), but when looking from below, we might need to adjust. For the Cornell box, the camera is usually above, so up is (0,1,0). Mouse drag: left button changes theta and phi. Wheel changes radius. When the user interacts, set frameIndex = 0. Now, scene data. Let me define a classic Cornell box: - Room size: width 2, height 2, depth 2. Center at (0,1,0). So x ∈ [-1,1], y ∈ [0,2], z ∈ [-1,1]. - Left wall: red, x = -1. - Right wall: green, x = 1. - Back wall: white, z = -1. - Floor: white, y = 0. - Ceiling: white, y = 2, with a light source area in the middle. - Front: open (camera looks in from positive z). Light source: a rectangle on the ceiling, centered at (0, 1.95, 0), size 0.5 x 0.5, facing downward. Objects: - A short box (diffuse) on the left. - A tall/thin box (diffuse) on the right. - Or a sphere. Classic Cornell box has two boxes, but a sphere is also fine. I'll add: - A diffuse box on the left. - A metal sphere in the middle. - A glass sphere on the right. But the user requested physically correct. Metal and glass are more complex. To keep the shader simpler, I can use only diffuse materials and two boxes. But adding a metal sphere makes it more visually interesting. Actually, the classic Cornell box has diffuse materials. I'll stick with diffuse to ensure physical correctness and simplicity. I can add one diffuse sphere and two boxes. Materials: 0: white diffuse (0.9, 0.9, 0.9) 1: red diffuse (0.9, 0.1, 0.1) 2: green diffuse (0.1, 0.9, 0.1) 3: light (1,1,1) emission high 4: yellow diffuse (0.9, 0.8, 0.1) for sphere 5: white diffuse for tall box Walls as triangles. Let me list all triangles: Floor (y=0): two triangles, white. - (-1,0,-1), (1,0,-1), (1,0,1) - (-1,0,-1), (1,0,1), (-1,0,1) Ceiling (y=2): two triangles, white, but with a hole for the light? Or make the light a separate emissive rectangle slightly below the ceiling. Classic approach: light source is a recessed panel on the ceiling. We can make the ceiling with a hole, and the light is a rectangle at y=1.99. Simpler: the entire ceiling is white, but a rectangle in the middle is emissive (slightly below). This avoids holes. The light rectangle is at y=1.98, size 0.6x0.6. Actually, for correct physical simulation, the light source should not intersect the ceiling. Place the light at y=1.99, and the ceiling above it at y=2. The light is a standalone rectangle. Let's define: - Ceiling: y=2, x∈[-1,1], z∈[-1,1], white. - Light: y=1.99, x∈[-0.3,0.3], z∈[-0.3,0.3], emissive, normal downward. Back wall: z=-1, x∈[-1,1], y∈[0,2], white. Left wall: x=-1, y∈[0,2], z∈[-1,1], red. Right wall: x=1, green. That makes 5 walls * 2 triangles = 10 triangles, plus 2 light triangles = 12 triangles, plus objects. Objects: - Box 1 (short): centered at (-0.4, 0.3, 0.2), size 0.6 x 0.6 x 0.6, rotated 20 degrees. Yellow. - Box 2 (tall): centered at (0.4, 0.6, -0.2), size 0.6 x 1.2 x 0.6, rotated -15 degrees. White. - Sphere: center (0, 0.5, 0.5), radius 0.25, white or metal. Defining rotated boxes with triangles is a bit tedious. I can define axis-aligned boxes and rotate them using transformation matrices. Or manually compute the 8 vertices and 12 triangles. For simplicity, I'll use axis-aligned boxes or spheres. The user might not care if it's exactly the classic Cornell box. But a Cornell box usually has boxes. Let me implement a box as 12 triangles. I'll define a function to create a box given center and size, then rotate vertices. In the shader, I can precompute the rotated vertex coordinates in JavaScript and pass them as uniforms? But the user wants the shader to contain the scene. I can hardcode the vertex coordinates after rotation. Alternatively, in the shader, define a box by center, size, and rotation angle, and compute intersections using ray-box intersection with inverse transformation. This is more efficient. But for simplicity, I'll hardcode the triangles. Let me compute the vertices for two rotated boxes. Box 1: center (-0.4, 0.3, 0.2), size (0.6, 0.6, 0.6), rotated 20 degrees around y-axis. Half-size (0.3, 0.3, 0.3). Rotation matrix: cos20 ≈ 0.9397, sin20 ≈ 0.3420. For each vertex (±0.3, ±0.3, ±0.3), rotate: x' = x*cos - z*sin z' = x*sin + z*cos then add center. Box 2: center (0.4, 0.6, -0.2), size (0.6, 1.2, 0.6), rotated -15 degrees around y-axis. Half-size (0.3, 0.6, 0.3). cos15≈0.9659, sin15≈0.2588. This is tedious but feasible. I'll compute the coordinates in JS and embed them in the shader as constants. Actually, I can compute them in JS and pass them to the shader as uniforms at initialization. But the user wants inline; passing uniforms is fine. Wait, the user wants a self-contained HTML file. Passing scene data via uniforms is acceptable and more flexible. But for simplicity, I'll hardcode the triangle vertices in the shader. I can compute the values and write them directly. Alternatively, I can generate the shader source in JS by interpolating the computed coordinates. This keeps the shader dynamic and avoids manual calculation errors. Let me do that. In JS, define scene data: walls, light, boxes, spheres. Compute triangle vertices for boxes. Then generate GLSL code strings to initialize the triangles array in the shader. This is a good approach. The shader template will have placeholders like `// TRIANGLES_DATA //` which get replaced with initialization code. But the user wants inline JS; generating shader source dynamically is fine. Let me design the data structures in JS: ```js const materials = [ { albedo: [0.9,0.9,0.9], emission: [0,0,0], type: 0 }, // white { albedo: [0.9,0.1,0.1], emission: [0,0,0], type: 0 }, // red { albedo: [0.1,0.9,0.1], emission: [0,0,0], type: 0 }, // green { albedo: [1,1,1], emission: [20,20,20], type: 3 }, // light { albedo: [0.9,0.8,0.1], emission: [0,0,0], type: 0 }, // yellow ]; const spheres = [ { center: [0,0.5,0.5], radius: 0.25, mat: 0 } ]; function createBox(center, size, angleY, mat) { ... returns 12 triangles ... } const boxes = [ createBox([-0.4,0.3,0.2], [0.6,0.6,0.6], 20, 4), createBox([0.4,0.6,-0.2], [0.6,1.2,0.6], -15, 0) ]; const walls = [... triangles for walls and light ...]; const triangles = [...walls, ...boxes.flat()]; ``` Then generate GLSL initialization: ```js let triangleInit = ''; for (let i = 0; i < triangles.length; i++) { const t = triangles[i]; triangleInit += `triangles[${i}] = Triangle(vec3(${t.v0.join(',')}), vec3(${t.v1.join(',')}), vec3(${t.v2.join(',')}), ${t.mat});\n`; } ``` Similarly for spheres and materials. The shader will have arrays of fixed size. We need to know the maximum sizes. In JS, after generating the scene, we know the counts. We can set the array sizes in the shader using string replacement. This is more robust than hardcoding. Now, for the light source, I'll define it as a rectangle with 2 triangles, material 3. In the shader, I need to find the light source for direct lighting. I'll pass the light rectangle data as uniforms or hardcode it. Since there's only one light, I can hardcode its corners