Click or drag anywhere on the canvas to move the light source. The sphere is rendered pixel by pixel using the Phong equation, so you can watch each of the three terms change as the light moves.
<!-- Sphere canvas — Phong shading rendered pixel by pixel -->
<canvas id="sphere" width="300" height="300" title="Phong-shaded sphere — click or drag to move the light"></canvas>
<div class="controls">
<div class="toggle-row">
<button id="btnA" class="tog active" type="button">Ambient</button>
<button id="btnD" class="tog active" type="button">Diffuse</button>
<button id="btnS" class="tog active" type="button">Specular</button>
</div>
<label class="slider-label">
Shininess (n):
<input id="shine" type="range" min="2" max="128" value="32">
<span id="shineVal">32</span>
</label>
</div>
<p class="hint">Click or drag on the sphere to move the light source.</p>
/* Layout and button styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; display: flex;
flex-direction: column; align-items: center; gap: .6rem; }
canvas { border-radius: 50%; cursor: crosshair;
box-shadow: 0 2px 12px rgba(0,0,0,.25); display: block; }
.controls { display: flex; flex-direction: column; gap: .5rem; width: 300px; }
.toggle-row { display: flex; gap: .5rem; }
.tog { flex: 1; padding: .4rem .5rem; border: 1.5px solid #1d3557;
border-radius: 8px; cursor: pointer; font: 600 13px system-ui;
background: #1d3557; color: #fff; transition: background .15s; }
.tog:not(.active) { background: #fff; color: #1d3557; }
.slider-label { display: flex; align-items: center; gap: .5rem;
font: 14px system-ui; color: #333; }
input[type=range] { flex: 1; accent-color: #1d3557; }
#shineVal { font: 700 14px ui-monospace, monospace; width: 2.5rem; text-align: right; }
.hint { font-size: .85rem; color: #555; margin: 0; text-align: center; }
// Phong illumination: I = Ka*Ia + Kd*(N.L)*Id + Ks*(R.V)^n*Is
// Ka/Kd/Ks = ambient/diffuse/specular material coefficients; n = shininess exponent
const canvas = document.getElementById('sphere');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const R = W / 2 - 4; // sphere radius in pixels, with a small margin
// Light position in normalised screen space (z computed on the fly)
let lx = -0.6, ly = -0.6;
let useA = true, useD = true, useS = true;
let shine = 32;
// Material reflectance coefficients
const Ka = 0.15, Kd = 0.75, Ks = 0.6;
// Light colour components (white light)
const La = [1, 1, 1], Ld = [1, 1, 1], Ls = [1, 1, 1];
// Surface base colour (steel blue)
const surfR = 0.18, surfG = 0.42, surfB = 0.78;
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function normalize(v) {
const len = Math.sqrt(dot(v, v));
return len > 0 ? [v[0]/len, v[1]/len, v[2]/len] : [0, 0, 0];
}
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
// Render the sphere pixel by pixel using the Phong equation
function draw() {
const img = ctx.createImageData(W, H);
const cx = W / 2, cy = H / 2;
// Project 2D light position onto the unit hemisphere
const lz = Math.sqrt(Math.max(0, 1 - lx*lx - ly*ly));
const L = normalize([lx, ly, lz]);
// Camera looks straight down the +Z axis
const V = [0, 0, 1];
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const nx = (px - cx) / R;
const ny = (py - cy) / R;
const nz2 = 1 - nx*nx - ny*ny;
if (nz2 < 0) continue; // pixel is outside the sphere disc
const nz = Math.sqrt(nz2);
const N = [nx, ny, nz];
// Lambert diffuse: N dot L
const NdL = clamp01(dot(N, L));
// Reflection vector R = 2(N.L)N - L
const R_vec = [2*NdL*N[0]-L[0], 2*NdL*N[1]-L[1], 2*NdL*N[2]-L[2]];
const RdV = clamp01(dot(R_vec, V));
const spec = Math.pow(RdV, shine);
// Sum the three Phong terms, each gated by its toggle
let r = 0, g = 0, b = 0;
if (useA) { r += Ka*La[0]*surfR; g += Ka*La[1]*surfG; b += Ka*La[2]*surfB; }
if (useD) { r += Kd*NdL*Ld[0]*surfR; g += Kd*NdL*Ld[1]*surfG; b += Kd*NdL*Ld[2]*surfB; }
if (useS) { r += Ks*spec*Ls[0]; g += Ks*spec*Ls[1]; b += Ks*spec*Ls[2]; }
const idx = (py * W + px) * 4;
img.data[idx] = clamp01(r) * 255;
img.data[idx+1] = clamp01(g) * 255;
img.data[idx+2] = clamp01(b) * 255;
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// Draw a small yellow dot marking the light position
const dotX = cx + lx * R;
const dotY = cy + ly * R;
ctx.beginPath();
ctx.arc(dotX, dotY, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,230,80,0.9)';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Mouse and touch event handlers for moving the light
function setLight(e) {
const rect = canvas.getBoundingClientRect();
const cx = W / 2, cy = H / 2;
const scaleX = W / rect.width, scaleY = H / rect.height;
let x = ((e.clientX - rect.left) * scaleX - cx) / R;
let y = ((e.clientY - rect.top) * scaleY - cy) / R;
// Keep the light within the sphere disc
const len = Math.sqrt(x*x + y*y);
if (len > 0.95) { x = x/len*0.95; y = y/len*0.95; }
lx = x; ly = y;
draw();
}
let dragging = false;
canvas.addEventListener('mousedown', e => { dragging = true; setLight(e); });
canvas.addEventListener('mousemove', e => { if (dragging) setLight(e); });
canvas.addEventListener('mouseup', () => dragging = false);
canvas.addEventListener('mouseleave',() => dragging = false);
canvas.addEventListener('touchstart', e => { e.preventDefault(); setLight(e.touches[0]); }, {passive:false});
canvas.addEventListener('touchmove', e => { e.preventDefault(); setLight(e.touches[0]); }, {passive:false});
// Toggle each Phong term on/off
document.getElementById('btnA').addEventListener('click', function() {
useA = !useA; this.classList.toggle('active', useA); draw();
});
document.getElementById('btnD').addEventListener('click', function() {
useD = !useD; this.classList.toggle('active', useD); draw();
});
document.getElementById('btnS').addEventListener('click', function() {
useS = !useS; this.classList.toggle('active', useS); draw();
});
// Shininess slider — updates the specular exponent
const shineInput = document.getElementById('shine');
const shineVal = document.getElementById('shineVal');
shineInput.addEventListener('input', function() {
shine = parseInt(this.value, 10);
shineVal.textContent = shine;
draw();
});
draw();
<!-- Sphere canvas — Phong shading rendered pixel by pixel -->
<canvas id="sphere" width="300" height="300" title="Phong-shaded sphere — click or drag to move the light"></canvas>
<div class="controls">
<div class="toggle-row">
<button id="btnA" class="tog active" type="button">Ambient</button>
<button id="btnD" class="tog active" type="button">Diffuse</button>
<button id="btnS" class="tog active" type="button">Specular</button>
</div>
<label class="slider-label">
Shininess (n):
<input id="shine" type="range" min="2" max="128" value="32">
<span id="shineVal">32</span>
</label>
</div>
<p class="hint">Click or drag on the sphere to move the light source.</p>
/* Layout and button styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; display: flex;
flex-direction: column; align-items: center; gap: .6rem; }
canvas { border-radius: 50%; cursor: crosshair;
box-shadow: 0 2px 12px rgba(0,0,0,.25); display: block; }
.controls { display: flex; flex-direction: column; gap: .5rem; width: 300px; }
.toggle-row { display: flex; gap: .5rem; }
.tog { flex: 1; padding: .4rem .5rem; border: 1.5px solid #1d3557;
border-radius: 8px; cursor: pointer; font: 600 13px system-ui;
background: #1d3557; color: #fff; transition: background .15s; }
.tog:not(.active) { background: #fff; color: #1d3557; }
.slider-label { display: flex; align-items: center; gap: .5rem;
font: 14px system-ui; color: #333; }
input[type=range] { flex: 1; accent-color: #1d3557; }
#shineVal { font: 700 14px ui-monospace, monospace; width: 2.5rem; text-align: right; }
.hint { font-size: .85rem; color: #555; margin: 0; text-align: center; }
// Phong illumination: I = Ka*Ia + Kd*(N.L)*Id + Ks*(R.V)^n*Is
// Ka/Kd/Ks = ambient/diffuse/specular material coefficients; n = shininess exponent
const canvas = document.getElementById('sphere');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
const R = W / 2 - 4; // sphere radius in pixels, with a small margin
// Light position in normalised screen space (z computed on the fly)
let lx = -0.6, ly = -0.6;
let useA = true, useD = true, useS = true;
let shine = 32;
// Material reflectance coefficients
const Ka = 0.15, Kd = 0.75, Ks = 0.6;
// Light colour components (white light)
const La = [1, 1, 1], Ld = [1, 1, 1], Ls = [1, 1, 1];
// Surface base colour (steel blue)
const surfR = 0.18, surfG = 0.42, surfB = 0.78;
function dot(a, b) { return a[0]*b[0] + a[1]*b[1] + a[2]*b[2]; }
function normalize(v) {
const len = Math.sqrt(dot(v, v));
return len > 0 ? [v[0]/len, v[1]/len, v[2]/len] : [0, 0, 0];
}
function clamp01(x) { return Math.max(0, Math.min(1, x)); }
// Render the sphere pixel by pixel using the Phong equation
function draw() {
const img = ctx.createImageData(W, H);
const cx = W / 2, cy = H / 2;
// Project 2D light position onto the unit hemisphere
const lz = Math.sqrt(Math.max(0, 1 - lx*lx - ly*ly));
const L = normalize([lx, ly, lz]);
// Camera looks straight down the +Z axis
const V = [0, 0, 1];
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
const nx = (px - cx) / R;
const ny = (py - cy) / R;
const nz2 = 1 - nx*nx - ny*ny;
if (nz2 < 0) continue; // pixel is outside the sphere disc
const nz = Math.sqrt(nz2);
const N = [nx, ny, nz];
// Lambert diffuse: N dot L
const NdL = clamp01(dot(N, L));
// Reflection vector R = 2(N.L)N - L
const R_vec = [2*NdL*N[0]-L[0], 2*NdL*N[1]-L[1], 2*NdL*N[2]-L[2]];
const RdV = clamp01(dot(R_vec, V));
const spec = Math.pow(RdV, shine);
// Sum the three Phong terms, each gated by its toggle
let r = 0, g = 0, b = 0;
if (useA) { r += Ka*La[0]*surfR; g += Ka*La[1]*surfG; b += Ka*La[2]*surfB; }
if (useD) { r += Kd*NdL*Ld[0]*surfR; g += Kd*NdL*Ld[1]*surfG; b += Kd*NdL*Ld[2]*surfB; }
if (useS) { r += Ks*spec*Ls[0]; g += Ks*spec*Ls[1]; b += Ks*spec*Ls[2]; }
const idx = (py * W + px) * 4;
img.data[idx] = clamp01(r) * 255;
img.data[idx+1] = clamp01(g) * 255;
img.data[idx+2] = clamp01(b) * 255;
img.data[idx+3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// Draw a small yellow dot marking the light position
const dotX = cx + lx * R;
const dotY = cy + ly * R;
ctx.beginPath();
ctx.arc(dotX, dotY, 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,230,80,0.9)';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = 1.5;
ctx.stroke();
}
// Mouse and touch event handlers for moving the light
function setLight(e) {
const rect = canvas.getBoundingClientRect();
const cx = W / 2, cy = H / 2;
const scaleX = W / rect.width, scaleY = H / rect.height;
let x = ((e.clientX - rect.left) * scaleX - cx) / R;
let y = ((e.clientY - rect.top) * scaleY - cy) / R;
// Keep the light within the sphere disc
const len = Math.sqrt(x*x + y*y);
if (len > 0.95) { x = x/len*0.95; y = y/len*0.95; }
lx = x; ly = y;
draw();
}
let dragging = false;
canvas.addEventListener('mousedown', e => { dragging = true; setLight(e); });
canvas.addEventListener('mousemove', e => { if (dragging) setLight(e); });
canvas.addEventListener('mouseup', () => dragging = false);
canvas.addEventListener('mouseleave',() => dragging = false);
canvas.addEventListener('touchstart', e => { e.preventDefault(); setLight(e.touches[0]); }, {passive:false});
canvas.addEventListener('touchmove', e => { e.preventDefault(); setLight(e.touches[0]); }, {passive:false});
// Toggle each Phong term on/off
document.getElementById('btnA').addEventListener('click', function() {
useA = !useA; this.classList.toggle('active', useA); draw();
});
document.getElementById('btnD').addEventListener('click', function() {
useD = !useD; this.classList.toggle('active', useD); draw();
});
document.getElementById('btnS').addEventListener('click', function() {
useS = !useS; this.classList.toggle('active', useS); draw();
});
// Shininess slider — updates the specular exponent
const shineInput = document.getElementById('shine');
const shineVal = document.getElementById('shineVal');
shineInput.addEventListener('input', function() {
shine = parseInt(this.value, 10);
shineVal.textContent = shine;
draw();
});
draw();
Comments
Loading comments...