/**
* LittleJS Light System Plugin
* - Adds 2D dynamic lighting to the scene
* - Lights are first-class EngineObjects (the Light class)
* - Each Light draws a soft falloff blob of its color into a shared lightmap
* - Lights accumulate ADDITIVELY in the lightmap (red + blue = magenta)
* - The lightmap is then MULTIPLIED with the scene during composite, so unlit
* areas go to the ambient color and lit areas show the scene tinted by the
* accumulated light color
* - Draw the world at full brightness — the lightmap does the darkening
* - The pass runs after gameRenderPost, so a HUD drawn there with WebGL is
* darkened too; draw the HUD with useWebGL=false (the main canvas) or from
* a plugin created after this one
* - Any EngineObject may override renderLight() to additively contribute to the
* lightmap (e.g. lava that lights the floor around it, weapon flashes, glowing crystals)
* - Set obj.emissive to 1 to show an object at full brightness in its own colors, lit or not, or between 0 and 1 for
* partly: it draws its shape into the lightmap through renderEmissive(), which calls render() by default; exact for
* solid pixels, a partly transparent one is self lit by its alpha too, so it shows darker
* - Set lightSystem.shadows for objects to block light: each frame every object draws black into a
* shadow map through renderShadow(), which calls render() by default; obj.castShadow = false keeps it
* out (a floor TileLayer, a background), a draw's alpha sets how much light it blocks, and
* setShadowTransparent lets its color tint the light
* - Must be constructed BEFORE PostProcessPlugin so post-process sees lit pixels
* @namespace LightSystem
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
/** Global Light System plugin object
* @type {LightSystemPlugin}
* @memberof LightSystem */
let lightSystem;
///////////////////////////////////////////////////////////////////////////////
/**
* LightSystemPlugin
* - Owns the offscreen lightmap texture, falloff/composite shaders, and the
* per-frame render pass that multiplies the lightmap onto the WebGL scene
* - The composite is MULTIPLICATIVE: unlit areas get the ambient color, lit
* areas show the scene tinted by the accumulated light color. So you should
* draw your world at full brightness — the lightmap handles the darkening.
* @memberof LightSystem
*/
class LightSystemPlugin
{
/** Create the global light system plugin.
* @param {Vector2} [textureSize] - Size of the lightmap texture (defaults to following mainCanvasSize, which is css pixels, so the lightmap is not scaled by canvasPixelRatio; pass mainCanvasSize.scale(getCanvasPixelRatio()) for a full resolution lightmap)
* @param {Color} [ambientColor] - Color applied to unlit areas of the scene (defaults to BLACK = pitch dark). Set a small RGB like rgb(0.1,0.1,0.15) for a faint "moonlight" baseline so unlit areas aren't fully black.
* @example
* // simplest usage
* new LightSystemPlugin();
*/
constructor(textureSize, ambientColor)
{
ASSERT(engineInitialized || headlessMode, 'create the plugin after engineInit, e.g. in gameInit');
ASSERT(!lightSystem, 'LightSystemPlugin already initialized');
ASSERT(!postProcess, 'LightSystemPlugin must be created before PostProcessPlugin');
lightSystem = this;
/** @property {boolean} - When false, the render pass is skipped entirely */
this.enabled = true;
/** @property {Color} - Baseline color applied to unlit areas of the scene. Defaults to BLACK (pitch dark). Set to a small RGB for a faint ambient. The lightmap is cleared to this color each frame, then lights add on top, then the result multiplies the scene. */
this.ambientColor = (ambientColor || BLACK).copy();
/** @property {Vector2} - Size of the lightmap texture, follows mainCanvasSize (css pixels, so it is not scaled by canvasPixelRatio) unless a size was passed */
this.textureSize = textureSize ? textureSize.copy() : undefined;
/** @property {boolean} - True when no size was passed, so the lightmap follows mainCanvasSize */
this.textureSizeAuto = !textureSize;
/** @property {WebGLTexture|undefined} - The lightmap texture
* @type {WebGLTexture|undefined} */
this.texture = undefined;
/** @property {WebGLProgram|undefined} - Shader for drawing per-Light falloff blobs into the lightmap
* @type {WebGLProgram|undefined} */
this.lightShader = undefined;
/** @property {WebGLProgram|undefined} - Shader for compositing the lightmap over the main scene
* @type {WebGLProgram|undefined} */
this.compositeShader = undefined;
/** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the light shader
* @type {WebGLVertexArrayObject|undefined} */
this.lightVAO = undefined;
/** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the composite shader
* @type {WebGLVertexArrayObject|undefined} */
this.compositeVAO = undefined;
/** @property {boolean} - Cast shadows: every object draws black into a shadow map once a frame and each light's rays stop at them; off by default and free when off */
this.shadows = false;
/** @property {number} - Pixels across the square shadow map, made again when changed */
this.shadowMapSize = 1024;
/** @property {number} - How many times the larger side of the view the shadow map covers, so casters just off screen still cast in; raise it when lights reach further than a view past the screen */
this.shadowMapScale = 2;
/** @property {number} - Pixels across each light's own shadow texture, made again when changed; larger is sharper,
* and a gap between casters narrower than about 4*radius/shadowTextureSize world units closes */
this.shadowTextureSize = 256;
/** @property {number} - Stretch passes per shadow casting light, fewer is cheaper and shorter shadows */
this.shadowPassCount = 16;
/** @property {number} - How much light bleeds into a caster's near side, 0 for hard edged casters, 1 for most;
* the bleed reaches further in under a bigger light, so a thin wall under a big one lets some through, lower it
* for those */
this.shadowSoftness = .5;
/** @property {boolean} - True while the shadow pass runs, read only, so a render() can skip parts that should not cast */
this.shadowPass = false;
/** @property {boolean} - True while emissive objects draw into the lightmap, read only, see EngineObject.emissive */
this.emissivePass = false;
/** @property {WebGLTexture|undefined} - The shadow map, casters drawn black on white around the camera, read only
* @type {WebGLTexture|undefined} */
this.shadowMap = undefined;
/** @property {WebGLTexture|undefined} - One of the two textures each light's shadow is built in
* @type {WebGLTexture|undefined} */
this.shadowTextureA = undefined;
/** @property {WebGLTexture|undefined} - The other
* @type {WebGLTexture|undefined} */
this.shadowTextureB = undefined;
/** @property {WebGLProgram|undefined} - Copies the shadow map around a light into its texture
* @type {WebGLProgram|undefined} */
this.shadowCopyShader = undefined;
/** @property {WebGLProgram|undefined} - One stretch pass of a light's shadow texture
* @type {WebGLProgram|undefined} */
this.shadowStretchShader = undefined;
/** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the copy shader
* @type {WebGLVertexArrayObject|undefined} */
this.shadowCopyVAO = undefined;
/** @property {WebGLVertexArrayObject|undefined} - Vertex array object for the stretch shader
* @type {WebGLVertexArrayObject|undefined} */
this.shadowStretchVAO = undefined;
/** @property {OffscreenCanvasRenderingContext2D|undefined} - Where Canvas2D draws go during the shadow and emissive passes, a 1x1 canvas, so text in a render() is not drawn twice
* @type {OffscreenCanvasRenderingContext2D|undefined} */
this.shadowContext = undefined;
/** @property {Vector2} - World position of the shadow map's bottom left corner, set each shadow pass */
this.shadowMapOrigin = vec2();
/** @property {number} - World size the shadow map covers, set each shadow pass */
this.shadowMapWorldSize = 0;
this.shadowMapSizeAllocated = 0; // sizes the textures were made at, to remake them on a change
this.shadowTextureSizeAllocated = 0;
initLightSystem();
engineAddPlugin(undefined, lightSystemRender,
lightSystemContextLost, lightSystemContextRestored);
function initLightSystem()
{
if (headlessMode) return;
if (!glEnable)
{
console.warn('LightSystemPlugin: WebGL not enabled!');
return;
}
// where Canvas2D draws go during the shadow and emissive passes
lightSystem.shadowContext ||= new OffscreenCanvas(1, 1).getContext('2d');
// resolve texture size default at init time (mainCanvasSize may
// not be set yet at the moment the constructor first ran), and
// again on a context restore, the canvas may have changed since
if (lightSystem.textureSizeAuto)
lightSystem.textureSize = mainCanvasSize.copy();
// allocate the lightmap texture with null data at textureSize
lightSystem.texture = glContext.createTexture();
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
lightSystem.textureSize.x, lightSystem.textureSize.y, 0,
glContext.RGBA, glContext.UNSIGNED_BYTE, null);
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.LINEAR);
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.LINEAR);
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE);
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE);
// light falloff shader: one quad per Light, fragment computes radial falloff
lightSystem.lightShader = glCreateProgram(
'#version 300 es\n' +
'precision highp float;'+
'uniform mat4 m;'+
'uniform vec2 lightPos;'+
'uniform float radius;'+
'in vec2 g;'+ // unit quad geometry [0..1]
'out vec2 vWorldPos;'+
'out vec2 vUV;'+ // across the light's quad, the shadow texture's uv
'void main(){'+
'vec2 worldP=lightPos+(g-.5)*2.*radius;'+
'gl_Position=m*vec4(worldP,1,1);'+
'vWorldPos=worldP;'+
'vUV=g;'+
'}'
,
'#version 300 es\n' +
'precision highp float;'+
'uniform vec2 lightPos;'+
'uniform float radius;'+
'uniform float fadeRange;'+
'uniform vec4 color;'+
'uniform sampler2D shadowTexture;'+ // this light's shadow, white where its rays reach
'uniform bool useShadow;'+
'in vec2 vWorldPos;'+
'in vec2 vUV;'+
'out vec4 c;'+
'void main(){'+
'float dist=distance(vWorldPos,lightPos);'+
'float t=clamp((radius-dist)/max(fadeRange,1e-6),0.,1.);'+
'c=vec4(color.rgb*t*color.a,1.);'+
'if(useShadow)c.rgb*=texture(shadowTexture,vUV).rgb;'+
'}'
);
// the shadow texture is on unit 1, the engine's tracked texture stays on unit 0
glContext.useProgram(lightSystem.lightShader);
glContext.uniform1i(glUniformLocation(lightSystem.lightShader, 'shadowTexture'), 1);
// composite shader: fullscreen quad, samples the lightmap
lightSystem.compositeShader = glCreateProgram(
'#version 300 es\n' +
'precision highp float;'+
'in vec2 p;'+
'void main(){'+
'gl_Position=vec4(p+p-1.,1,1);'+
'}'
,
'#version 300 es\n' +
'precision highp float;'+
'uniform sampler2D s;'+
'uniform vec3 iResolution;'+
'out vec4 c;'+
'void main(){'+
'vec2 uv=gl_FragCoord.xy/iResolution.xy;'+
'c=vec4(texture(s,uv).rgb,1.);'+
'}'
);
// one quad VAO per program, the engine's unit triangle strip through the named attribute
lightSystem.lightVAO = createQuadVAO(lightSystem.lightShader, 'g');
lightSystem.compositeVAO = createQuadVAO(lightSystem.compositeShader, 'p');
}
function createQuadVAO(program, attribute)
{
const gl = glContext, vao = gl.createVertexArray();
gl.bindVertexArray(vao);
gl.bindBuffer(gl.ARRAY_BUFFER, glGeometryBuffer);
const location = gl.getAttribLocation(program, attribute);
gl.enableVertexAttribArray(location);
gl.vertexAttribPointer(location, 2, gl.FLOAT, false, 8, 0);
return vao;
}
function createTexture(size)
{
const gl = glContext, texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, size, size, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
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);
return texture;
}
function initShadows()
{
const gl = glContext, ls = lightSystem;
ls.shadowMap = createTexture(ls.shadowMapSize);
ls.shadowTextureA = createTexture(ls.shadowTextureSize);
ls.shadowTextureB = createTexture(ls.shadowTextureSize);
ls.shadowMapSizeAllocated = ls.shadowMapSize;
ls.shadowTextureSizeAllocated = ls.shadowTextureSize;
// put back the texture the engine tracks
if (glActiveTexture)
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
const quadVertex =
'#version 300 es\n' +
'precision highp float;'+
'in vec2 p;'+ // unit quad [0..1]
'out vec2 uv;'+
'void main(){gl_Position=vec4(p+p-1.,1,1);uv=p;}';
// copy: the shadow map around the light into the light's texture, light at the center
ls.shadowCopyShader = glCreateProgram(quadVertex,
'#version 300 es\n' +
'precision highp float;'+
'uniform sampler2D s;'+ // the shadow map
'uniform vec2 lightPos;'+
'uniform float radius;'+
'uniform vec2 mapOrigin;'+ // world bottom left of the map
'uniform float mapInvSize;'+ // 1 over the world size it covers
'uniform float tap;'+ // half a texel of the light's texture, in world units
'uniform float core;'+ // radius around the light where casters are left out
'in vec2 uv;'+
'out vec4 c;'+
'void main(){'+
'vec2 w=lightPos+(uv-.5)*2.*radius;'+ // world position of this texel
'vec3 o=vec3(1);'+
// the darkest of four taps a half texel out, so a caster a texel thin still covers two whole
// texels, the stretch's filtering between two would leak through one
'for(int i=0;i<4;i++){'+
'vec2 m=(w+tap*(vec2(i%2,i/2)*2.-1.)-mapOrigin)*mapInvSize;'+ // in the map, world up at v=0
// past the map's edge is open, clamping would stretch its edge texels across the light
'o=min(o,m==clamp(m,0.,1.)?texture(s,vec2(m.x,1.-m.y)).rgb:vec3(1));'+
'}'+
'c=vec4(length(w-lightPos)<core?vec3(1):o,1);'+
'}');
// stretch: each texel keeps the darkest of itself and the texel toward the light, so a caster's
// alpha and color carry through as they are; the bleed lights a caster's near side only where
// the ray from the light still reaches, so it never lifts a texel already in shadow
ls.shadowStretchShader = glCreateProgram(quadVertex,
'#version 300 es\n' +
'precision highp float;'+
'uniform sampler2D s;'+ // the previous pass
'uniform float scale;'+ // how much further out this pass pushes the casters
'uniform float brightness;'+ // light bled into the near side of casters this pass
'in vec2 uv;'+
'out vec4 c;'+
'void main(){'+
'float mask=1.-smoothstep(.35,.525,length(uv-.5));'+ // full out to 70% of the radius, FrankEngine's light mask
'vec3 b=texture(s,(uv-.5)/scale+.5).rgb;'+
'c=vec4(min(texture(s,uv).rgb+brightness*mask*b,b),1);'+
'}');
ls.shadowCopyVAO = createQuadVAO(ls.shadowCopyShader, 'p');
ls.shadowStretchVAO = createQuadVAO(ls.shadowStretchShader, 'p');
// the plugin samples on unit 1 so the engine's tracked texture on unit 0 is untouched
gl.useProgram(ls.shadowCopyShader);
gl.uniform1i(glUniformLocation(ls.shadowCopyShader, 's'), 1);
gl.useProgram(ls.shadowStretchShader);
gl.uniform1i(glUniformLocation(ls.shadowStretchShader, 's'), 1);
}
function freeShadows()
{
const gl = glContext, ls = lightSystem;
gl.deleteTexture(ls.shadowMap);
gl.deleteTexture(ls.shadowTextureA);
gl.deleteTexture(ls.shadowTextureB);
gl.deleteProgram(ls.shadowCopyShader);
gl.deleteProgram(ls.shadowStretchShader);
gl.deleteVertexArray(ls.shadowCopyVAO);
gl.deleteVertexArray(ls.shadowStretchVAO);
clearShadows();
}
function clearShadows()
{
const ls = lightSystem;
ls.shadowMap = ls.shadowTextureA = ls.shadowTextureB = undefined;
ls.shadowCopyShader = ls.shadowStretchShader = undefined;
ls.shadowCopyVAO = ls.shadowStretchVAO = undefined;
}
function lightSystemShadowPass()
{
const ls = lightSystem;
// make the resources the first time, and again when a size changed
if (!ls.shadowMap || ls.shadowMapSize !== ls.shadowMapSizeAllocated
|| ls.shadowTextureSize !== ls.shadowTextureSizeAllocated)
{
ls.shadowMap && freeShadows();
initShadows();
}
// a square of world space around the camera, rounded to its own texels so the
// grid stays put in the world as the camera moves, or the shadows would shimmer
const size = ls.shadowMapSize;
const view = mainCanvasSize.scale(1/cameraScale);
const worldSize = ls.shadowMapScale * max(view.x, view.y);
const texel = worldSize / size;
const center = vec2(floor(cameraPos.x/texel)*texel, floor(cameraPos.y/texel)*texel);
ls.shadowMapOrigin = center.subtract(vec2(worldSize/2));
ls.shadowMapWorldSize = worldSize;
// draw with the map's camera, the way a tile layer redraw does; Canvas2D draws
// go to a 1x1 canvas so text in a render() does not reach the screen twice
const saved = [drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader];
drawContext = ls.shadowContext;
mainCanvasSize = vec2(size);
cameraPos = center;
cameraScale = size / worldSize;
cameraAngle = 0;
canvasClearColor = WHITE;
glSetRenderTarget(ls.shadowMap, true);
glSkipScreenSpace = true; // the map's camera would put them anywhere
ls.shadowPass = true;
try
{
for (const o of engineObjects)
{
if (o.destroyed || !o.castShadow) continue;
// every color black, its alpha kept, set for each object since a render that left
// setShadowTransparent on ends with it
glColorMask = 0xff000000;
setShader(o.shader); // its own Shader as in the main pass, so a snippet that cuts holes casts the same shape
o.renderShadow();
}
}
finally
{
// hand everything back even when a render threw, or every frame after it draws black with no text
ls.shadowPass = false;
glColorMask = -1;
glSkipScreenSpace = false;
glSetRenderTarget();
[drawContext, mainCanvasSize, cameraPos, cameraScale, cameraAngle, canvasClearColor, glCustomShader] = saved;
}
}
function lightSystemRender()
{
if (headlessMode || !glEnable) return;
if (!lightSystem.enabled) return;
if (!lightSystem.texture)
{
// made now if WebGL was off when the plugin was made, or when a lost context came back
if (glContext.isContextLost()) return;
initLightSystem();
if (!lightSystem.texture) return;
}
// 1. flush any in-flight sprite batch from earlier render passes
glFlush();
const prevAdditive = glAdditive;
// 1b. the shadow pass draws every caster black into the shadow map
if (lightSystem.shadows)
lightSystemShadowPass();
// an automatic size follows the canvas, so reallocate the lightmap when
// the canvas changed size, after the flush so the batch keeps its texture
const size = lightSystem.textureSize;
if (lightSystem.textureSizeAuto &&
(size.x !== mainCanvasSize.x || size.y !== mainCanvasSize.y))
{
lightSystem.textureSize = mainCanvasSize.copy();
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
glContext.texImage2D(glContext.TEXTURE_2D, 0, glContext.RGBA,
mainCanvasSize.x, mainCanvasSize.y, 0,
glContext.RGBA, glContext.UNSIGNED_BYTE, null);
// put back the texture the engine tracks, a draw in renderLight must not sample the lightmap
if (glActiveTexture)
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
}
// 2. bind lightmap as render target, clear to ambientColor
const ac = lightSystem.ambientColor;
glContext.bindFramebuffer(glContext.FRAMEBUFFER, glFramebuffer);
glContext.framebufferTexture2D(glContext.FRAMEBUFFER,
glContext.COLOR_ATTACHMENT0, glContext.TEXTURE_2D, lightSystem.texture, 0);
glContext.viewport(0, 0, lightSystem.textureSize.x, lightSystem.textureSize.y);
glContext.clearColor(ac.r, ac.g, ac.b, ac.a);
glContext.clear(glContext.COLOR_BUFFER_BIT);
// 3. walk engineObjects calling renderLight() — additive blend
// (lightmap accumulates raw additive color contributions)
setAdditiveBlendMode();
glContext.enable(glContext.BLEND);
glContext.blendFunc(glContext.ONE, glContext.ONE);
// the camera transform is the same for every light, and a uniform
// keeps its value per program, so set it once for the whole pass.
// It is the canvas transform glPreRender built: world→NDC over
// mainCanvasSize (not textureSize), the viewport handles the
// lightmap's actual resolution. No y-flip: the composite samples
// this FBO with gl_FragCoord/iResolution (origin bottom-left), so
// storing world +Y at the top of the texture lines up with the canvas.
const ls = lightSystem.lightShader;
glContext.useProgram(ls);
glContext.uniformMatrix4fv(glUniformLocation(ls, 'm'), false, glTransform);
glSetInstancedMode(true);
// a target set and ended while the lights draw, a tile layer redrawn in a renderLight, comes back here
glRenderTargetBase = [lightSystem.texture, lightSystem.textureSize];
try
{
for (const o of engineObjects)
{
if (o.destroyed) continue;
glAdditive || setAdditiveBlendMode(); // added again, an emitter ends its render with it off
o.renderLight();
}
// 3b. emissive objects draw their shape in grey at their emissive level, white at 1, adding that much
// light where they are so they show their own colors; text goes to the 1x1 canvas as in the
// shadow pass, so it is not drawn twice
const saved = [drawContext, glCustomShader];
drawContext = lightSystem.shadowContext;
lightSystem.emissivePass = true;
try
{
for (const o of engineObjects)
{
if (o.destroyed || !(o.emissive > 0)) continue;
const level = clamp(o.emissive)*255+.5|0; // packed like rgbaInt, red in the low byte
glColorMask = 0xff000000; // its own alpha, and the grey from the additive color
glColorAdditive = level | level<<8 | level<<16;
glAdditive || setAdditiveBlendMode(); // added, an emitter ends its render with it off
setShader(o.shader);
o.renderEmissive();
}
glFlush();
}
finally
{
lightSystem.emissivePass = false;
glColorMask = -1;
glColorAdditive = 0;
[drawContext, glCustomShader] = saved;
}
}
finally
{
glRenderTargetBase = undefined;
}
// 4. drain any sprite-batched draws (e.g. drawTile inside a
// custom renderLight override) so they hit the FBO, not the
// canvas after we unbind
glFlush();
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null);
// backing store size, mainCanvasSize is css pixels
glContext.viewport(0, 0, glCanvas.width, glCanvas.height);
// 5. composite: fullscreen quad, multiplicative blend onto glCanvas
// (scene * lightmap — unlit areas go to black, lit areas are
// the scene tinted by the accumulated light color)
glContext.useProgram(lightSystem.compositeShader);
glContext.bindVertexArray(lightSystem.compositeVAO);
glContext.activeTexture(glContext.TEXTURE0);
glContext.bindTexture(glContext.TEXTURE_2D, lightSystem.texture);
const cs = lightSystem.compositeShader;
glContext.uniform1i(glUniformLocation(cs, 's'), 0);
glContext.uniform3f(glUniformLocation(cs, 'iResolution'),
mainCanvas.width, mainCanvas.height, 1);
glContext.blendFunc(glContext.DST_COLOR, glContext.ZERO);
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
// 6. restore engine state so subsequent draws use the engine's
// tracked texture binding (otherwise glSetTexture would think
// the prior texture was still bound when actually the lightmap
// is, and any debug text / future draw could sample the lightmap)
if (glActiveTexture)
glContext.bindTexture(glContext.TEXTURE_2D, glActiveTexture);
setAdditiveBlendMode(prevAdditive);
glSetInstancedMode(true);
}
function lightSystemContextLost()
{
lightSystem.texture = undefined;
lightSystem.lightShader = undefined;
lightSystem.compositeShader = undefined;
lightSystem.lightVAO = undefined;
lightSystem.compositeVAO = undefined;
clearShadows();
LOG('LightSystemPlugin: WebGL context lost');
}
function lightSystemContextRestored()
{
initLightSystem();
LOG('LightSystemPlugin: WebGL context restored');
}
}
/** Draw a single Light's falloff blob into the currently bound lightmap.
* Called by Light.renderLight() during the plugin's render pass.
* @param {Light} light */
drawLight(light)
{
if (headlessMode || !glEnable || !this.lightShader) return;
// skip a light that can not touch the screen, its quad is the full
// radius out from its center on every side
if (!isOnScreen(light.pos, light.radius*2)) return;
// drain any sprite-batched draws queued by a previous custom
// renderLight() override (e.g. drawRect inside a LavaTile). They were
// queued in the engine's instanced-vertex format and must flush with
// the engine's shader+VAO bound — NOT this plugin's light shader.
glFlush();
// a shadow casting light builds its shadow texture first, leaving it on unit 1 and the lightmap bound
const useShadow = this.shadows && light.castShadow && !!this.shadowMap;
useShadow && this.renderLightShadow(light);
glContext.useProgram(this.lightShader);
glContext.bindVertexArray(this.lightVAO);
glContext.enable(glContext.BLEND);
glContext.blendFunc(glContext.ONE, glContext.ONE); // added, whatever a tile layer redrawn before it left set
// the camera transform 'm' was set once for the pass by the plugin
const ls = this.lightShader;
glContext.uniform2f(glUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y);
glContext.uniform1f(glUniformLocation(ls, 'radius'), light.radius);
glContext.uniform1f(glUniformLocation(ls, 'fadeRange'), light.fadeRange);
glContext.uniform1i(glUniformLocation(ls, 'useShadow'), useShadow ? 1 : 0);
const c = light.color;
glContext.uniform4f(glUniformLocation(ls, 'color'), c.r, c.g, c.b, c.a);
glContext.drawArrays(glContext.TRIANGLE_STRIP, 0, 4);
// restore engine's instanced shader+VAO so subsequent renderLight()
// overrides that batch through drawRect/drawTile work correctly
glSetInstancedMode(true);
}
/** Build a light's shadow texture from the shadow map: the map around the light, its casters stretched
* away from the light a little further each pass with light bled into their near sides. Leaves the
* result on texture unit 1 and the lightmap bound again. Called by drawLight.
* @param {Light} light */
renderLightShadow(light)
{
const gl = glContext, size = this.shadowTextureSize;
gl.disable(gl.BLEND);
gl.viewport(0, 0, size, size);
gl.activeTexture(gl.TEXTURE1);
// the shadow map around the light, light at the center of the texture
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.shadowTextureA, 0);
const cs = this.shadowCopyShader;
gl.useProgram(cs);
gl.bindVertexArray(this.shadowCopyVAO);
gl.bindTexture(gl.TEXTURE_2D, this.shadowMap);
gl.uniform2f(glUniformLocation(cs, 'lightPos'), light.pos.x, light.pos.y);
gl.uniform1f(glUniformLocation(cs, 'radius'), light.radius);
gl.uniform2f(glUniformLocation(cs, 'mapOrigin'), this.shadowMapOrigin.x, this.shadowMapOrigin.y);
gl.uniform1f(glUniformLocation(cs, 'mapInvSize'), 1/this.shadowMapWorldSize);
gl.uniform1f(glUniformLocation(cs, 'tap'), light.radius/size);
gl.uniform1f(glUniformLocation(cs, 'core'), light.shadowCore);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
// stretch the casters out from the light, starting a 128th of the texture and growing 1.5x a pass, which
// reaches the edge in about 16 passes with no gaps since each pass scales by less than the shadow already
// extends; small steps, so the bleed does not show as stairs along the shadow edges; a fraction of the
// texture rather than a count of texels, so a larger texture only makes the shadows sharper, not shorter;
// the bleed fades out over the first 10 passes, the distances FrankEngine's soften covered at 1.8x
const ss = this.shadowStretchShader;
gl.useProgram(ss);
gl.bindVertexArray(this.shadowStretchVAO);
let src = this.shadowTextureA, dst = this.shadowTextureB;
for (let k = 0; k < this.shadowPassCount; ++k)
{
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, dst, 0);
gl.bindTexture(gl.TEXTURE_2D, src);
gl.uniform1f(glUniformLocation(ss, 'scale'), 1 + 2*1.5**k/256);
gl.uniform1f(glUniformLocation(ss, 'brightness'), clamp((10.1-k)/7.2)**2 * this.shadowSoftness);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
[src, dst] = [dst, src];
}
// leave the result on unit 1 for the light shader and go back to the lightmap
gl.bindTexture(gl.TEXTURE_2D, src);
gl.activeTexture(gl.TEXTURE0);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.texture, 0);
gl.viewport(0, 0, this.textureSize.x, this.textureSize.y);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE);
}
/** In the shadow pass, let the draws that follow keep their color in the shadow map, so light passing
* through them is tinted instead of blocked: a stained glass window, colored smoke. Any draw blocks light
* by its alpha, so a fading sprite casts a fading shadow; this keeps the color as well. It covers what was drawn
* under it in the map as any draw does, so glass drawn after a wall cuts a tinted window in the wall's shadow, and
* a wall drawn after the glass covers it. Does nothing outside
* the pass, so a render() can call it around those draws unconditionally; set it back to false after them.
* @param {boolean} [transparent] */
setShadowTransparent(transparent=true)
{
// the mask applies as each draw is queued, so nothing needs flushing
if (this.shadowPass)
glColorMask = transparent ? -1 : 0xff000000;
}
}
///////////////////////////////////////////////////////////////////////////////
/**
* A Light is an EngineObject that contributes a soft additive blob of color
* to the LightSystem plugin's lightmap.
* - castShadow on a Light means its rays stop at casters when lightSystem.shadows is on, three.js's meaning
* for a light; a Light's own render() draws nothing so the object meaning never applies to it
* - A light inside a caster is blocked entirely, so the object that holds it, its lamp, a torch, the player
* carrying it, needs a shadowCore that reaches past it, castShadow = false, or a renderShadow that leaves
* the light's spot out
* @extends EngineObject
* @memberof LightSystem
* @example
* new Light(vec2(5, 5), 4, rgb(1, 0.5, 0)); // orange light, full soft blob
* new Light(vec2(0, 0), 8, rgb(1, 1, 1), 2); // white core with 2-unit soft halo
*/
class Light extends EngineObject
{
/** Create a light object and add it to the engine object list
* @param {Vector2} pos - World space position
* @param {number} radius - Total extent of the light in world units
* @param {Color} [color] - Color of the light; alpha modulates intensity
* @param {number} [fadeRange] - Width of the soft edge in world units (defaults to radius) */
constructor(pos, radius, color, fadeRange)
{
super(pos, vec2(1), undefined, 0, color);
this.mass = 0; // static, a light stays where it is put in a game with gravity
ASSERT(isNumber(radius) && radius >= 0, 'Light radius must be a non-negative number');
ASSERT(fadeRange === undefined || (isNumber(fadeRange) && fadeRange >= 0),
'Light fadeRange must be a non-negative number when provided');
/** @property {number} - Total extent of the light in world units */
this.radius = radius;
/** @property {number} - Width of the soft edge in world units */
this.fadeRange = fadeRange === undefined ? radius : fadeRange;
/** @property {number} - Radius around the light where casters are left out of its shadow, so the lamp
* or torch that holds it, or the player carrying it, does not block it; it has to reach past that object's
* corners, about half its diagonal and a little more, or dark rays run out from them */
this.shadowCore = 0;
}
/** Lights are invisible in the main render pass — they only contribute
* to the lightmap via renderLight(). */
render() {}
/** Draw this light's falloff blob into the lightmap.
* Called by LightSystemPlugin during its render pass. No-op when the
* plugin or WebGL is unavailable. */
renderLight()
{
lightSystem && lightSystem.drawLight(this);
}
}