/*
* Copyright 2024 The Ray Optics Simulation authors and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* An experimental canvas renderer for the light layer with the same API as `CanvasRenderer`, but uses the WebGL floating point texture and properly calculates color mixtures. This largely solves the issue of brightness and color inconsistency issue in this simulator, especially whe "Simulate Colors" is enabled.
*
* This renderer is currently used only in the web app when "Correct Brightness" is enabled, and is not used in generation of Gallery images and the automatic tests.
* @class
*/
class FloatColorRenderer {
/**
* Maximum number of elements in the cache before flushing.
* @type {number}
*/
static MAX_CACHE_SIZE = 500;
constructor(gl, origin, scale, lengthScale, backgroundImage, ctxVirtual, colorMode) {
this.gl = gl;
if (!this.gl) {
throw new Error('Unable to initialize WebGL. Your browser may not support it.');
}
// Enable floating point texture
const ext = this.gl.getExtension('OES_texture_float');
if (!ext) {
throw new Error('OES_texture_float not supported');
}
this.canvas = gl.canvas;
this.width = this.canvas.width;
this.height = this.canvas.height;
this.origin = origin;
this.scale = scale;
this.lengthScale = lengthScale;
this.colorMode = colorMode;
this.rayCache = [];
this.segmentCache = [];
this.pointCache = [];
this.arrowCache = [];
this.hasFirstFlush = false;
switch (this.colorMode) {
case 'colorizedIntensity':
this.msaaCount = 1; // Colorized intensity does not work well with MSAA since the color is not additive
break;
default:
this.msaaCount = 4;
}
// Create reusable buffers
this.lineBuffer = this.gl.createBuffer();
this.pointBuffer = this.gl.createBuffer();
this.arrowBuffer = this.gl.createBuffer();
this.initializeShaders();
this.initializeFramebuffer();
}
/**
* Initialize the framebuffer and textures.
*/
initializeFramebuffer() {
const gl = this.gl;
// Create floating point texture
this.floatTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, this.floatTexture);
// Use RGBA format for the texture
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.canvas.width, gl.canvas.height, 0, gl.RGBA, gl.FLOAT, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
// Create and set up framebuffer
this.framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.floatTexture, 0);
// Check framebuffer status
const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
if (status !== gl.FRAMEBUFFER_COMPLETE) {
throw new Error('Framebuffer is not complete: ' + status);
}
// Create quad buffer for final render
this.quadBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1,
1, -1,
-1, 1,
1, 1
]), gl.STATIC_DRAW);
// Reset bindings
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}
/**
* Initialize shaders.
*/
initializeShaders() {
// Vertex shader for rays
const rayVertexSource = `
attribute vec2 position;
uniform vec2 u_resolution;
uniform vec2 u_origin;
uniform float u_scale;
uniform bool u_isScreenSpace;
varying vec2 v_position;
varying vec2 v_resolution;
void main() {
vec2 pos;
if (u_isScreenSpace) {
pos = position;
} else {
pos = position * u_scale + u_origin;
}
// Convert to clip space
vec2 clipSpace = (pos / u_resolution) * 2.0 - 1.0;
clipSpace.y = -clipSpace.y;
gl_Position = vec4(clipSpace, 0.0, 1.0);
// Pass values to fragment shader
v_position = pos;
v_resolution = u_resolution;
}
`;
// Fragment shader for rays - just output color for additive blending
const rayFragmentSource = `
precision highp float;
uniform vec4 u_color;
uniform bool u_isScreenSpace;
varying vec2 v_position;
varying vec2 v_resolution;
void main() {
gl_FragColor = u_color;
}
`;
// Vertex shader for points
const pointVertexSource = `
attribute vec2 position;
uniform vec2 u_resolution;
uniform vec2 u_origin;
uniform float u_scale;
uniform vec2 u_point;
uniform float u_size;
varying vec2 v_position;
varying vec2 v_resolution;
void main() {
// Calculate point position
vec2 pointPos = u_point * u_scale + u_origin;
vec2 pos = pointPos + position * u_size * u_scale;
// Convert to clip space
vec2 clipSpace = (pos / u_resolution) * 2.0 - 1.0;
clipSpace.y = -clipSpace.y;
gl_Position = vec4(clipSpace, 0.0, 1.0);
// Pass values to fragment shader
v_position = pos;
v_resolution = u_resolution;
}
`;
// Fragment shader for points - same as rays
const pointFragmentSource = rayFragmentSource;
// Vertex shader for final pass
const quadVertexSource = `
attribute vec2 position;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
v_texCoord = position * 0.5 + 0.5;
}
`;
// Fragment shader for final pass
let quadFragmentSource;
switch (this.colorMode) {
case 'linear':
quadFragmentSource = `
precision highp float;
uniform sampler2D u_texture;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
float maxComponent = max(max(color.r, color.g), color.b);
if (maxComponent > 1.0) {
color.rgb /= maxComponent;
}
gl_FragColor = vec4(pow(color.rgb, vec3(1.0 / 2.2)), pow(min(maxComponent, 1.0), 1.0 / 2.2));
}
`;
break;
case 'linearRGB':
quadFragmentSource = `
precision highp float;
uniform sampler2D u_texture;
varying vec2 v_texCoord;
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
float maxComponent = max(max(color.r, color.g), color.b);
gl_FragColor = vec4(pow(color.rgb, vec3(1.0 / 2.2)), pow(maxComponent, 1.0 / 2.2));
}
`;
break;
case 'reinhard':
quadFragmentSource = `
precision highp float;
uniform sampler2D u_texture;
varying vec2 v_texCoord;
// Convert RGB to luminance using perceptual weights
float getLuminance(vec3 color) {
return dot(color, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
// Sample the texture
vec4 color = texture2D(u_texture, v_texCoord);
// Calculate luminance
float Lold = getLuminance(color.rgb);
// Apply Reinhard tone mapping to luminance
float Lnew = Lold / (1.0 + Lold);
// Scale RGB by ratio of new to old luminance
vec3 toneMapped = color.rgb * (Lnew / Lold);
// Apply gamma correction
vec3 gammaCorrected = pow(toneMapped, vec3(1.0 / 2.2));
// Store the maximum component of the original color
float maxComponent = max(max(color.r, color.g), color.b);
gl_FragColor = vec4(gammaCorrected, pow(maxComponent, 1.0 / 2.2));
}
`;
break;
case 'colorizedIntensity':
quadFragmentSource = `
precision highp float;
uniform sampler2D u_texture;
varying vec2 v_texCoord;
vec4 brightnessToColor(float brightness) {
if (brightness > 100.0) {
return vec4(1.0, 0.0, 0.0, 1.0); // Red
} else if (brightness > 10.0) {
return vec4(mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 0.0, 0.0), (log2(brightness) - log2(10.0)) / (log2(100.0) - log2(10.0))), 1.0); // Smooth transition from orange to red
} else if (brightness > 1.0) {
return vec4(mix(vec3(1.0, 1.0, 0.0), vec3(1.0, 0.5, 0.0), (log2(brightness) - log2(1.0)) / (log2(10.0) - log2(1.0))), 1.0); // Smooth transition from yellow to orange
} else if (brightness > 0.1) {
return vec4(mix(vec3(0.0, 1.0, 0.0), vec3(1.0, 1.0, 0.0), (log2(brightness) - log2(0.1)) / (log2(1.0) - log2(0.1))), 1.0); // Smooth transition from green to yellow
} else if (brightness > 0.01) {
return vec4(mix(vec3(0.0, 1.0, 1.0), vec3(0.0, 1.0, 0.0), (log2(brightness) - log2(0.01)) / (log2(0.1) - log2(0.01))), 1.0); // Smooth transition from cyan to green
} else if (brightness > 0.001) {
return vec4(mix(vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 1.0), (log2(brightness) - log2(0.001)) / (log2(0.01) - log2(0.001))), 1.0); // Smooth transition from blue to cyan
} else if (brightness > 0.0001) {
return vec4(mix(vec3(0.3, 0.0, 0.3), vec3(0.0, 0.0, 1.0), (log2(brightness) - log2(0.0001)) / (log2(0.001) - log2(0.0001))), 1.0); // Smooth transition from purple to blue
} else {
return vec4(mix(vec3(0.0, 0.0, 0.0), vec3(0.3, 0.0, 0.3), (log2(max(brightness, 1e-7)) - log2(1e-7)) / (log2(0.0001) - log2(1e-7))), (log2(max(brightness, 1e-7)) - log2(1e-7)) / (log2(0.0001) - log2(1e-7))); // Smooth transition from purple to transparent
}
}
void main() {
vec4 color = texture2D(u_texture, v_texCoord);
float maxComponent = max(max(color.r, color.g), color.b);
vec4 mappedColor = brightnessToColor(maxComponent);
gl_FragColor = vec4(mappedColor.rgb * 0.8, 0.0);
}
`
break;
}
// Compile ray shaders
const rayVertexShader = this.compileShader(this.gl, rayVertexSource, this.gl.VERTEX_SHADER);
const rayFragmentShader = this.compileShader(this.gl, rayFragmentSource, this.gl.FRAGMENT_SHADER);
// Create ray program
this.rayProgram = this.gl.createProgram();
this.gl.attachShader(this.rayProgram, rayVertexShader);
this.gl.attachShader(this.rayProgram, rayFragmentShader);
this.gl.linkProgram(this.rayProgram);
if (!this.gl.getProgramParameter(this.rayProgram, this.gl.LINK_STATUS)) {
this.gl.deleteProgram(this.rayProgram);
return;
}
// Get ray program locations
this.positionAttributeLocation = this.gl.getAttribLocation(this.rayProgram, 'position');
this.resolutionUniformLocation = this.gl.getUniformLocation(this.rayProgram, 'u_resolution');
this.originUniformLocation = this.gl.getUniformLocation(this.rayProgram, 'u_origin');
this.scaleUniformLocation = this.gl.getUniformLocation(this.rayProgram, 'u_scale');
this.isScreenSpaceLocation = this.gl.getUniformLocation(this.rayProgram, 'u_isScreenSpace');
this.colorUniformLocation = this.gl.getUniformLocation(this.rayProgram, 'u_color');
// Compile point shaders
const pointVertexShader = this.compileShader(this.gl, pointVertexSource, this.gl.VERTEX_SHADER);
const pointFragmentShader = this.compileShader(this.gl, pointFragmentSource, this.gl.FRAGMENT_SHADER);
// Create point program
this.pointProgram = this.gl.createProgram();
this.gl.attachShader(this.pointProgram, pointVertexShader);
this.gl.attachShader(this.pointProgram, pointFragmentShader);
this.gl.linkProgram(this.pointProgram);
if (!this.gl.getProgramParameter(this.pointProgram, this.gl.LINK_STATUS)) {
this.gl.deleteProgram(this.pointProgram);
return;
}
// Get point program locations
this.pointPositionAttributeLocation = this.gl.getAttribLocation(this.pointProgram, 'position');
this.pointResolutionUniformLocation = this.gl.getUniformLocation(this.pointProgram, 'u_resolution');
this.pointOriginUniformLocation = this.gl.getUniformLocation(this.pointProgram, 'u_origin');
this.pointScaleUniformLocation = this.gl.getUniformLocation(this.pointProgram, 'u_scale');
this.pointPositionUniformLocation = this.gl.getUniformLocation(this.pointProgram, 'u_point');
this.pointSizeUniformLocation = this.gl.getUniformLocation(this.pointProgram, 'u_size');
this.pointColorUniformLocation = this.gl.getUniformLocation(this.pointProgram, 'u_color');
// Compile quad shaders
const quadVertexShader = this.compileShader(this.gl, quadVertexSource, this.gl.VERTEX_SHADER);
const quadFragmentShader = this.compileShader(this.gl, quadFragmentSource, this.gl.FRAGMENT_SHADER);
// Create quad program
this.quadProgram = this.gl.createProgram();
this.gl.attachShader(this.quadProgram, quadVertexShader);
this.gl.attachShader(this.quadProgram, quadFragmentShader);
this.gl.linkProgram(this.quadProgram);
if (!this.gl.getProgramParameter(this.quadProgram, this.gl.LINK_STATUS)) {
this.gl.deleteProgram(this.quadProgram);
return;
}
// Get quad program locations
this.quadPositionLocation = this.gl.getAttribLocation(this.quadProgram, 'position');
this.textureLocation = this.gl.getUniformLocation(this.quadProgram, 'u_texture');
// Create point vertices (unit square)
const pointVertices = new Float32Array([
-0.5, -0.5,
0.5, -0.5,
-0.5, 0.5,
0.5, 0.5
]);
this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.pointBuffer);
this.gl.bufferData(this.gl.ARRAY_BUFFER, pointVertices, this.gl.STATIC_DRAW);
}
/**
* Compile a shader.
* @param {WebGLRenderingContext} gl
* @param {string} source
* @param {number} type
* @returns {WebGLShader}
*/
compileShader(gl, source, type) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* Begin the drawing process.
* This method is called at the beginning of each frame.
*/
begin() {
this.rayCache = [];
this.segmentCache = [];
this.pointCache = [];
this.arrowCache = [];
this.hasFirstFlush = false;
}
/**
* Draw a point.
* @param {Point} p
* @param {number[]} [color=[0, 0, 0, 1]]
* @param {number} [size=5]
*/
drawPoint(p, color = [0, 0, 0, 1], size = 5) {
color = this.preprocessColor(color);
this.pointCache.push({ p, color, size });
// If cache is too large, flush immediately
if (this.pointCache.length >= FloatColorRenderer.MAX_CACHE_SIZE) {
this.flush();
}
}
/**
* Draw a ray.
* @param {Line} r
* @param {number[]} [color=[0, 0, 0, 1]]
* @param {boolean} [showArrow=false]
* @param {number[]} [lineDash=[]]
*/
drawRay(r, color = [0, 0, 0, 1], showArrow = false, lineDash = []) {
// Check if ray has a valid direction
const dx = r.p2.x - r.p1.x;
const dy = r.p2.y - r.p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length < 1e-5 * this.lengthScale) {
return;
}
color = this.preprocessColor(color);
const unitX = dx / length;
const unitY = dy / length;
// Calculate canvas limit for ray length
const cvsLimit = (Math.abs(r.p1.x + this.origin.x) + Math.abs(r.p1.y + this.origin.y) + this.canvas.height + this.canvas.width) / Math.min(1, this.scale);
// Calculate arrow parameters
const arrowSize = 5 * this.lengthScale;
const arrowDistance = 150 * this.lengthScale;
if (showArrow && arrowSize >= this.lengthScale * 1.2) {
// Draw first part (from start to arrow)
const firstSegment = {
p1: r.p1,
p2: {
x: r.p1.x + unitX * arrowDistance,
y: r.p1.y + unitY * arrowDistance
}
};
if (lineDash && lineDash.length > 0) {
this.drawDashedSegment(firstSegment, color, lineDash);
} else {
this.segmentCache.push({ s: firstSegment, color });
}
// Add arrow
const arrowX = r.p1.x + unitX * arrowDistance;
const arrowY = r.p1.y + unitY * arrowDistance;
const perpX = -unitY;
const perpY = unitX;
const baseWidth = this.lengthScale;
const tipWidth = arrowSize;
this.arrowCache.push({
points: [
// Front points (wide part)
arrowX - (tipWidth/2) * perpX,
arrowY - (tipWidth/2) * perpY,
arrowX + (tipWidth/2) * perpX,
arrowY + (tipWidth/2) * perpY,
// Back points (narrow part)
arrowX + arrowSize * unitX + (baseWidth/2) * perpX,
arrowY + arrowSize * unitY + (baseWidth/2) * perpY,
arrowX + arrowSize * unitX - (baseWidth/2) * perpX,
arrowY + arrowSize * unitY - (baseWidth/2) * perpY
],
color
});
// Draw second part (from arrow to infinity)
const secondRay = {
p1: {
x: arrowX + arrowSize * unitX,
y: arrowY + arrowSize * unitY
},
p2: {
x: r.p1.x + unitX * cvsLimit,
y: r.p1.y + unitY * cvsLimit
}
};
if (lineDash && lineDash.length > 0) {
this.drawDashedSegment(secondRay, color, lineDash);
} else {
this.rayCache.push({ r: secondRay, color });
}
} else {
// Draw without arrow
if (lineDash && lineDash.length > 0) {
// For dashed lines, create segments
let dashPos = 0;
let isDraw = true;
let currentPos = 0;
while (currentPos < cvsLimit) {
const dashLength = lineDash[dashPos] * this.lengthScale;
if (isDraw) {
const segStart = {
x: r.p1.x + currentPos * unitX,
y: r.p1.y + currentPos * unitY
};
const segEnd = {
x: r.p1.x + Math.min(currentPos + dashLength, cvsLimit) * unitX,
y: r.p1.y + Math.min(currentPos + dashLength, cvsLimit) * unitY
};
this.segmentCache.push({
s: { p1: segStart, p2: segEnd },
color
});
}
currentPos += dashLength;
dashPos = (dashPos + 1) % lineDash.length;
isDraw = !isDraw;
}
} else if (this.msaaCount > 1) {
// For MSAA, create rays with subpixel offsets along the direction perpendicular to the ray with color averaging
const subpixelOffset = 1 / this.msaaCount;
const perpX = -unitY;
const perpY = unitX;
const dividedColor = [color[0], color[1], color[2], color[3] / this.msaaCount];
for (let i = 0; i < this.msaaCount; i++) {
const offset = (((i % 2 === 0 ? 1 : -1) * (Math.floor(i / 2) + 0.5)) * subpixelOffset) / this.scale;
const subpixelRay = {
p1: {
x: r.p1.x + offset * perpX,
y: r.p1.y + offset * perpY
},
p2: {
x: r.p1.x + unitX * cvsLimit + offset * perpX,
y: r.p1.y + unitY * cvsLimit + offset * perpY
}
};
this.rayCache.push({ r: subpixelRay, color: dividedColor });
}
} else {
this.rayCache.push({ r, color });
}
}
// If cache is too large, flush immediately
if (this.rayCache.length >= FloatColorRenderer.MAX_CACHE_SIZE ||
this.segmentCache.length >= FloatColorRenderer.MAX_CACHE_SIZE ||
this.arrowCache.length >= FloatColorRenderer.MAX_CACHE_SIZE) {
this.flush();
}
}
/**
* Draw a dashed segment.
* @param {Line} s
* @param {number[]} [color=[0, 0, 0, 1]]
* @param {number[]} [lineDash=[]]
*/
drawDashedSegment(s, color, lineDash) {
const dx = s.p2.x - s.p1.x;
const dy = s.p2.y - s.p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length < 1e-5 * this.lengthScale) {
return;
}
const unitX = dx / length;
const unitY = dy / length;
let dashPos = 0;
let isDraw = true;
let currentPos = 0;
while (currentPos < length) {
const dashLength = lineDash[dashPos] * this.lengthScale;
const remainingLength = length - currentPos;
if (isDraw) {
const segStart = {
x: s.p1.x + currentPos * unitX,
y: s.p1.y + currentPos * unitY
};
const segEnd = {
x: s.p1.x + Math.min(currentPos + dashLength, length) * unitX,
y: s.p1.y + Math.min(currentPos + dashLength, length) * unitY
};
this.segmentCache.push({
s: { p1: segStart, p2: segEnd },
color
});
}
currentPos += dashLength;
dashPos = (dashPos + 1) % lineDash.length;
isDraw = !isDraw;
if (currentPos >= length) break;
}
}
/**
* Draw a segment.
* @param {Line} s
* @param {number[]} [color=[0, 0, 0, 1]]
* @param {boolean} [showArrow=false]
* @param {number[]} [lineDash=[]]
*/
drawSegment(s, color = [0, 0, 0, 1], showArrow = false, lineDash = []) {
const dx = s.p2.x - s.p1.x;
const dy = s.p2.y - s.p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
const unitX = dx / length;
const unitY = dy / length;
if (length < 1e-5 * this.lengthScale) {
return;
}
color = this.preprocessColor(color);
const arrowSize = Math.min(length * 0.15, 5 * this.lengthScale);
const arrowPosition = 0.67;
if (showArrow && arrowSize >= this.lengthScale * 1.2) {
// Calculate arrow position
const arrowX = s.p1.x + dx * arrowPosition;
const arrowY = s.p1.y + dy * arrowPosition;
// Draw first part (from start to arrow)
const firstSegment = {
p1: s.p1,
p2: {
x: arrowX - arrowSize/2 * unitX,
y: arrowY - arrowSize/2 * unitY
}
};
if (lineDash && lineDash.length > 0) {
this.drawDashedSegment(firstSegment, color, lineDash);
} else {
this.segmentCache.push({ s: firstSegment, color });
}
// Add arrow
const perpX = -unitY;
const perpY = unitX;
const baseWidth = this.lengthScale;
const tipWidth = arrowSize;
this.arrowCache.push({
points: [
// Front points (wide part)
arrowX - arrowSize/2 * unitX - (tipWidth/2) * perpX,
arrowY - arrowSize/2 * unitY - (tipWidth/2) * perpY,
arrowX - arrowSize/2 * unitX + (tipWidth/2) * perpX,
arrowY - arrowSize/2 * unitY + (tipWidth/2) * perpY,
// Back points (narrow part)
arrowX + arrowSize/2 * unitX + (baseWidth/2) * perpX,
arrowY + arrowSize/2 * unitY + (baseWidth/2) * perpY,
arrowX + arrowSize/2 * unitX - (baseWidth/2) * perpX,
arrowY + arrowSize/2 * unitY - (baseWidth/2) * perpY
],
color
});
// Draw second part (from arrow to end)
const secondSegment = {
p1: {
x: arrowX + arrowSize/2 * unitX,
y: arrowY + arrowSize/2 * unitY
},
p2: s.p2
};
if (lineDash && lineDash.length > 0) {
this.drawDashedSegment(secondSegment, color, lineDash);
} else {
this.segmentCache.push({ s: secondSegment, color });
}
} else {
// Draw without arrow
if (lineDash && lineDash.length > 0) {
this.drawDashedSegment(s, color, lineDash);
} else if (this.msaaCount > 1) {
// For MSAA, create segments with subpixel offsets along the direction perpendicular to the ray with color averaging
const subpixelOffset = 1 / this.msaaCount;
const perpX = -unitY;
const perpY = unitX;
const dividedColor = [color[0], color[1], color[2], color[3] / this.msaaCount];
for (let i = 0; i < this.msaaCount; i++) {
const offset = (((i % 2 === 0 ? 1 : -1) * (Math.floor(i / 2) + 0.5)) * subpixelOffset) / this.scale;
const subpixelSegment = {
p1: {
x: s.p1.x + offset * perpX,
y: s.p1.y + offset * perpY
},
p2: {
x: s.p2.x + offset * perpX,
y: s.p2.y + offset * perpY
}
};
this.segmentCache.push({ s: subpixelSegment, color: dividedColor });
}
} else {
this.segmentCache.push({ s, color });
}
}
// If cache is too large, flush immediately
if (this.segmentCache.length >= FloatColorRenderer.MAX_CACHE_SIZE ||
this.arrowCache.length >= FloatColorRenderer.MAX_CACHE_SIZE) {
this.flush();
}
}
/**
* Flush the caches and render the accumulated rays, segments, and points.
* This method is called when the cache size exceeds the maximum cache size, when the simulation is paused, or when the simulation is completed.
*/
flush() {
if (!this.rayProgram || !this.quadProgram || !this.pointProgram) {
return;
}
const gl = this.gl;
const canvas = gl.canvas;
// First pass: render rays to floating point texture
gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer);
gl.viewport(0, 0, canvas.width, canvas.height);
// Only clear on first use
if (!this.hasFirstFlush) {
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT);
this.hasFirstFlush = true;
}
// Enable additive blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
// Draw points
if (this.pointCache.length > 0) {
gl.useProgram(this.pointProgram);
// Set shared uniforms
gl.uniform2f(this.pointResolutionUniformLocation, canvas.width, canvas.height);
gl.uniform2f(this.pointOriginUniformLocation, this.origin.x, this.origin.y);
gl.uniform1f(this.pointScaleUniformLocation, this.scale);
// Bind point vertices
gl.bindBuffer(gl.ARRAY_BUFFER, this.pointBuffer);
gl.enableVertexAttribArray(this.pointPositionAttributeLocation);
gl.vertexAttribPointer(this.pointPositionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
// Draw each point
this.pointCache.forEach(({ p, color, size }) => {
const [r, g, b, a] = color;
gl.uniform2f(this.pointPositionUniformLocation, p.x, p.y);
gl.uniform1f(this.pointSizeUniformLocation, size * this.lengthScale);
gl.uniform4f(this.pointColorUniformLocation, r * a, g * a, b * a, 1.0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
});
}
// Draw rays and segments
gl.useProgram(this.rayProgram);
// Set shared uniforms
gl.uniform2f(this.resolutionUniformLocation, canvas.width, canvas.height);
gl.uniform2f(this.originUniformLocation, this.origin.x, this.origin.y);
gl.uniform1f(this.scaleUniformLocation, this.scale);
// Use the reusable line buffer
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuffer);
gl.enableVertexAttribArray(this.positionAttributeLocation);
gl.vertexAttribPointer(this.positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
// Switch to scene space for rays and segments
gl.uniform1i(this.isScreenSpaceLocation, false);
// Draw rays
this.rayCache.forEach(({ r, color }) => {
const [r_, g, b, a] = color;
gl.uniform4f(this.colorUniformLocation, r_ * a, g * a, b * a, 1.0);
// Calculate canvas limit for ray length
const cvsLimit = (Math.abs(r.p1.x + this.origin.x) + Math.abs(r.p1.y + this.origin.y) + this.canvas.height + this.canvas.width) / Math.min(1, this.scale);
const rayEnd = {
x: r.p1.x + (r.p2.x - r.p1.x) * cvsLimit,
y: r.p1.y + (r.p2.y - r.p1.y) * cvsLimit
};
const vertices = this.createRectangleFromLine(r.p1, rayEnd);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Draw segments
this.segmentCache.forEach(({ s, color }) => {
const [r_, g, b, a] = color;
gl.uniform4f(this.colorUniformLocation, r_ * a, g * a, b * a, 1.0);
const vertices = this.createRectangleFromLine(s.p1, s.p2);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, 6);
});
// Draw arrows
if (this.arrowCache.length > 0) {
// Use the same program as rays and points
gl.useProgram(this.rayProgram);
gl.bindBuffer(gl.ARRAY_BUFFER, this.arrowBuffer);
gl.enableVertexAttribArray(this.positionAttributeLocation);
gl.vertexAttribPointer(this.positionAttributeLocation, 2, gl.FLOAT, false, 0, 0);
this.arrowCache.forEach(({ points, color }) => {
const [r, g, b, a] = color;
gl.uniform4f(this.colorUniformLocation, r * a, g * a, b * a, 1.0);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(points), gl.DYNAMIC_DRAW);
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
});
}
// Second pass: render floating point texture to screen with normalization
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0.0, 0.0, 0.0, 0.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// Disable blending for final pass
gl.disable(gl.BLEND);
gl.useProgram(this.quadProgram);
// Bind the floating point texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.floatTexture);
gl.uniform1i(this.textureLocation, 0);
// Draw fullscreen quad
gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuffer);
gl.enableVertexAttribArray(this.quadPositionLocation);
gl.vertexAttribPointer(this.quadPositionLocation, 2, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// Clear caches
this.rayCache.length = 0;
this.segmentCache.length = 0;
this.pointCache.length = 0;
this.arrowCache.length = 0;
}
/**
* Create a rectangle from a line segment. Since WebGL lineWidth is not widely supported, this method creates a rectangle with the given width to render the line segment or ray.
* @param {Point} p1
* @param {Point} p2
* @param {number} [width=1]
*/
createRectangleFromLine(p1, p2, width = 1) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const length = Math.sqrt(dx * dx + dy * dy);
if (length < 1e-5 * this.lengthScale) {
return new Float32Array(0);
}
// Calculate unit vectors
const unitX = dx / length;
const unitY = dy / length;
const perpX = -unitY * (width * this.lengthScale / 2);
const perpY = unitX * (width * this.lengthScale / 2);
// Create rectangle vertices
return new Float32Array([
// First triangle
p1.x + perpX, p1.y + perpY,
p1.x - perpX, p1.y - perpY,
p2.x + perpX, p2.y + perpY,
// Second triangle
p2.x + perpX, p2.y + perpY,
p1.x - perpX, p1.y - perpY,
p2.x - perpX, p2.y - perpY
]);
}
/**
* Preprocess color for gamma correction and normalization.
* @param {number[]} color
* @returns {number[]}
*/
preprocessColor(color) {
const r = color[0] * color[3];
const g = color[1] * color[3];
const b = color[2] * color[3];
if (r + g + b == 0) {
return [0, 0, 0, 0];
}
const m = Math.max(r, g, b);
switch (this.colorMode) {
case 'colorizedIntensity':
return [m, m, m, 1.0];
default:
// Correct gamma
const rr = Math.pow(r, 2.2);
const gg = Math.pow(g, 2.2);
const bb = Math.pow(b, 2.2);
const ratio = m / Math.max(rr, gg, bb);
return [rr * ratio, gg * ratio, bb * ratio, 1.0];
}
}
/**
* Destroy the renderer and release resources.
*/
destroy() {
const gl = this.gl;
// Delete buffers
gl.deleteBuffer(this.lineBuffer);
gl.deleteBuffer(this.pointBuffer);
gl.deleteBuffer(this.arrowBuffer);
gl.deleteBuffer(this.quadBuffer);
// Delete textures
gl.deleteTexture(this.floatTexture);
// Delete framebuffer
gl.deleteFramebuffer(this.framebuffer);
// Delete shader programs
gl.deleteProgram(this.rayProgram);
gl.deleteProgram(this.pointProgram);
gl.deleteProgram(this.quadProgram);
}
applyColorTransformation() {
// This is just to maintain the same API as `CanvasRenderer`. Color transformation is already done in the fragment shader in this renderer.
}
}
export default FloatColorRenderer;