import { Vector3 } from "./Vector.js";
/**
* Calculate refraction direction
* @param {number} n1 - Refractive index of the incident medium
* @param {number} n2 - Refractive index of the refracting medium
* @param {Vector3} incidentDir - Direction of the incident ray (propagation direction)
* @param {Vector3} normal - Normal at the interface (pointing from the incident medium to the refracting medium)
* @returns {Vector3|null} - Direction of the refracted ray, returns null if total internal reflection occurs
*/
function calculateRefraction(n1, n2, incidentDir, normal) {
// Normalize all vectors
const I = incidentDir.clone().normalize();
const N = normal.clone().normalize();
// Ensure the normal points into the correct hemisphere relative to the incident direction
const cosi = I.dot(N);
if (cosi > 0) {
N.scale(-1); // Flip the normal to point from the incident medium to the refracting medium
}
// Calculate the relative refractive index (incident to refracting)
const eta = n1 / n2;
const absCosi = Math.abs(cosi);
const sin2t = eta * eta * (1.0 - absCosi * absCosi);
// Total internal reflection check
if (sin2t > 1.0) return null;
// Calculate the refraction direction (using physically correct signs)
const k = Math.sign(cosi) * (eta * absCosi - Math.sqrt(1.0 - sin2t));
return I.scale(eta).add(N.scale(k)).normalize();
}
/**
* Handle refraction from air into a material
* @param {number} materialIOR - Refractive index of the material
* @param {Vector3} incidentDir - Direction of the incident ray (propagation direction)
* @param {Vector3} normal - Normal at the interface (pointing from air to the material)
* @returns {Vector3|null} - Direction of the refracted ray, returns null if total internal reflection occurs
*/
function airToMaterial(materialIOR, incidentDir, normal) {
return calculateRefraction(1.0, materialIOR, incidentDir, normal);
}
/**
* This function is used to define the surface shape of glass.
* @param {number} x - Normalized value, range [-1, 1]
* @param {number} y - Normalized value, range [-1, 1]
* @returns {number[]} - [bottom surface height, top surface height, normal x, normal y, normal z], (x, y, z) is the normal line's direction.
*/
function defineGlass(x, y) {
// ====== Adjustable Parameters ======
const midHeight = 0.1; // Middle height
const glassRadius = 1.0; // Maximum radius of the glass
// I tried different curve shapes to find the best effect
/* const a = 1.014306;
const b = 2.24165e-9;
const c = -23.07102; */
/* const a = 1.014306;
const b = 6.144129000000001e-29;
const c = -69.21305; */
const a = 0.999753;
const b = 2.163425e-9;
const c = -23.09065;
/* const a = 0.9868176;
const b = 2.874165e-14;
const c = -34.71221; */
// =====================
const r = Math.sqrt(x * x + y * y);
if (r <= 1e-4) {
return [0, midHeight, 0, 0, -1]; // Center point handling
}
if (r >= glassRadius) {
return [0, 0, 0, 0, -1]; // Handling outside the boundary
}
const t = r / glassRadius; // Normalized radius [0, 1]
/*
* Here I use a formula (latex) to simulate the surface shape of glass
* y = Y_{0} - \frac{V_{0}}{K}(1 - e^{-Kx})
*/
const baseHeight = a - (b / c) * (1 - Math.exp(-c * t));
// Correct height mapping (center: midHeight, edge: 0)
const height = midHeight * baseHeight;
// Calculate derivative (chain rule)
const dBaseHeight_dt = -b * Math.exp(-c * t);
const dHeight_dt = dBaseHeight_dt * midHeight; // Adjust sign and denominator
const dHeight_dr = dHeight_dt / glassRadius;
// Convert to x/y partial derivatives
const dhdx = dHeight_dr * x / r;
const dhdy = dHeight_dr * y / r;
// Calculate normal vector
const nx = dhdx;
const ny = dhdy;
const nz = 1;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);
return [
0, // Bottom surface height
height, // Top surface height
nx / len, // Normal x
ny / len, // Normal y
nz / len // Normal z
];
}
let ctx, glassCtx;
let isDragging = false;
let dragOffsetX, dragOffsetY;
document.addEventListener("DOMContentLoaded", function () {
const canvas = document.getElementById('canvas');
ctx = canvas.getContext('2d');
glassCtx = document.getElementById('glass').getContext('2d');
const img = new Image();
img.onload = function () {
canvas.setAttribute("width", img.width);
canvas.setAttribute("height", img.height);
ctx.drawImage(img, 0, 0);
};
img.src = '/background.png';
img.onerror = function () {
console.error('Fail to load image.');
};
glass.addEventListener('mousedown', (e) => {
isDragging = true;
const rect = glass.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
glass.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
const containerRect = canvas.getBoundingClientRect();
let newX = e.clientX - containerRect.left - dragOffsetX;
let newY = e.clientY - containerRect.top - dragOffsetY;
newX = Math.max(0, Math.min(containerRect.width - glass.offsetWidth, newX));
newY = Math.max(0, Math.min(containerRect.height - glass.offsetHeight, newY));
glass.style.left = `${newX}px`;
glass.style.top = `${newY}px`;
updateGlass();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
glass.style.cursor = 'move';
});
updateGlass();
});
/*
* Bilinear interpolation function
* This function determines the color to render in the glass canvas.
* Imagine a light perpendicular to the webpage entering the glass. After refraction, it intersects with a point on the image below. Based on the intersection point (usually a fractional coordinate), I blend the color values of the surrounding four points according to their weights to determine the color that should be rendered for the corresponding point on the glass.
*/
function getColorAtPosition(offsetX, offsetY, x, y) {
// Normalize coordinates
const x_ = x / 50 - 1;
const y_ = y / 50 - 1;
const [bottom, top, nx, ny, nz] = defineGlass(x_, y_);
if (top === 0) return { r: 0, g: 0, b: 0, a: 0 };
const normalVec = new Vector3(nx, ny, nz);
const inPoint = new Vector3(offsetX + x, offsetY + y, top + 20);
const innerLight = airToMaterial(1.5, new Vector3(0, 0, -1), normalVec);
const k = -inPoint.z / innerLight.z;
const dx = inPoint.x + k * innerLight.x;
const dy = inPoint.y + k * innerLight.y;
//console.log(dx, dy);
if (isNaN(dx) || isNaN(dy)) {
return [0, 0, 0, 0];
}
// Get four adjacent pixels
const x1 = Math.floor(dx);
const y1 = Math.floor(dy);
const x2 = x1 + 1;
const y2 = y1 + 1;
// Check if out of canvas bounds
const canvasWidth = ctx.canvas.width;
const canvasHeight = ctx.canvas.height;
// Helper function: check if coordinates are within canvas bounds
function getPixelSafe(x, y) {
if (x < 0 || x >= canvasWidth || y < 0 || y >= canvasHeight) {
return [0, 0, 0, 0]; // Out of bounds returns transparent color
}
return ctx.getImageData(x, y, 1, 1).data;
}
// Get color data for the four points
const pixel1 = getPixelSafe(x1, y1);
const pixel2 = getPixelSafe(x2, y1);
const pixel3 = getPixelSafe(x1, y2);
const pixel4 = getPixelSafe(x2, y2);
// Calculate weights
const wx = dx - x1;
const wy = dy - y1;
const w1 = (1 - wx) * (1 - wy);
const w2 = wx * (1 - wy);
const w3 = (1 - wx) * wy;
const w4 = wx * wy;
// Blend colors
const r = Math.round(
pixel1[0] * w1 +
pixel2[0] * w2 +
pixel3[0] * w3 +
pixel4[0] * w4
);
const g = Math.round(
pixel1[1] * w1 +
pixel2[1] * w2 +
pixel3[1] * w3 +
pixel4[1] * w4
);
const b = Math.round(
pixel1[2] * w1 +
pixel2[2] * w2 +
pixel3[2] * w3 +
pixel4[2] * w4
);
return { r, g, b, a: 255 };
}
// Update magnifying glass content
function updateGlass() {
// Clear the magnifying glass
glassCtx.clearRect(0, 0, glass.width, glass.height);
// Get the position of the magnifying glass
const glassRect = glass.getBoundingClientRect();
const containerRect = canvas.getBoundingClientRect();
const offsetX = glassRect.left - containerRect.left;
const offsetY = glassRect.top - containerRect.top;
// Create an ImageData object (allocate memory once)
const imageData = glassCtx.createImageData(glass.width, glass.height);
const data = imageData.data;
for (let y = 0; y < glass.height; y++) {
for (let x = 0; x < glass.width; x++) {
// Get blended color
const color = getColorAtPosition(offsetX, offsetY, x, y);
// Calculate the position of the pixel in ImageData (each pixel takes 4 bytes)
const pos = (y * glass.width + x) * 4;
// Set RGBA values
data[pos] = color.r; // R
data[pos + 1] = color.g; // G
data[pos + 2] = color.b; // B
data[pos + 3] = color.a; // A
}
}
// Draw all pixels at once (performance critical!)
glassCtx.putImageData(imageData, 0, 0);
}