UNPKG

littlejsengine

Version:

LittleJS - Tiny and Fast HTML5 Game Engine

331 lines (296 loc) 16.3 kB
/** * 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 * - Any EngineObject may override renderLight() to additively contribute to the * lightmap (e.g. emissive lava tiles, weapon flashes, glowing crystals) * - 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 mainCanvasSize) * @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(!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 (set at construction; falls back to mainCanvasSize at init time) */ this.textureSize = textureSize ? textureSize.copy() : undefined; /** @property {WebGLTexture} - The lightmap texture */ this.texture = undefined; /** @property {WebGLProgram} - Shader for drawing per-Light falloff blobs into the lightmap */ this.lightShader = undefined; /** @property {WebGLProgram} - Shader for compositing the lightmap over the main scene */ this.compositeShader = undefined; /** @property {WebGLVertexArrayObject} - Vertex array object for the light shader */ this.lightVAO = undefined; /** @property {WebGLVertexArrayObject} - Vertex array object for the composite shader */ this.compositeVAO = undefined; initLightSystem(); engineAddPlugin(undefined, lightSystemRender, lightSystemContextLost, lightSystemContextRestored); function initLightSystem() { if (headlessMode) return; if (!glEnable) { console.warn('LightSystemPlugin: WebGL not enabled!'); return; } // resolve texture size default at init time (mainCanvasSize may // not be set yet at the moment the constructor first ran) if (!lightSystem.textureSize) 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;'+ 'void main(){'+ 'vec2 worldP=lightPos+(g-.5)*2.*radius;'+ 'gl_Position=m*vec4(worldP,1,1);'+ 'vWorldPos=worldP;'+ '}' , '#version 300 es\n' + 'precision highp float;'+ 'uniform vec2 lightPos;'+ 'uniform float radius;'+ 'uniform float fadeRange;'+ 'uniform vec4 color;'+ 'in vec2 vWorldPos;'+ '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.);'+ '}' ); // 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.);'+ '}' ); // VAO for the per-Light quad — reuses the engine unit triangle-strip lightSystem.lightVAO = glContext.createVertexArray(); glContext.bindVertexArray(lightSystem.lightVAO); glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer); const gLight = glContext.getAttribLocation(lightSystem.lightShader, 'g'); glContext.enableVertexAttribArray(gLight); glContext.vertexAttribPointer(gLight, 2, glContext.FLOAT, false, 8, 0); // VAO for the composite fullscreen quad — same buffer, attribute named 'p' lightSystem.compositeVAO = glContext.createVertexArray(); glContext.bindVertexArray(lightSystem.compositeVAO); glContext.bindBuffer(glContext.ARRAY_BUFFER, glGeometryBuffer); const pComp = glContext.getAttribLocation(lightSystem.compositeShader, 'p'); glContext.enableVertexAttribArray(pComp); glContext.vertexAttribPointer(pComp, 2, glContext.FLOAT, false, 8, 0); } function lightSystemRender() { if (headlessMode || !glEnable) return; if (!lightSystem.enabled) return; if (!lightSystem.texture) return; // init failed or context lost // 1. flush any in-flight sprite batch from earlier render passes glFlush(); const prevAdditive = glAdditive; // 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); for (const o of engineObjects) o.destroyed || o.renderLight(); // 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); glContext.viewport(0, 0, mainCanvasSize.x, mainCanvasSize.y); // 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(glContext.getUniformLocation(cs, 's'), 0); glContext.uniform3f(glContext.getUniformLocation(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; 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; // 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(); glContext.useProgram(this.lightShader); glContext.bindVertexArray(this.lightVAO); // re-apply the engine camera transform onto this shader. Divide by // mainCanvasSize (not textureSize) so world→NDC matches the main // pass; the viewport handles the lightmap's actual resolution. // No y-flip here: 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 convention. const s = vec2(2*cameraScale).divide(mainCanvasSize); const rotatedCam = cameraPos.rotate(-cameraAngle); const p = vec2(-1).subtract(rotatedCam.multiply(s)); const ca = cos(cameraAngle); const sa = sin(cameraAngle); const transform = [ s.x * ca, s.y * sa, 0, 0, -s.x * sa, s.y * ca, 0, 0, 1, 1, 1, 0, p.x, p.y, 0, 1]; const ls = this.lightShader; glContext.uniformMatrix4fv(glContext.getUniformLocation(ls, 'm'), false, transform); glContext.uniform2f(glContext.getUniformLocation(ls, 'lightPos'), light.pos.x, light.pos.y); glContext.uniform1f(glContext.getUniformLocation(ls, 'radius'), light.radius); glContext.uniform1f(glContext.getUniformLocation(ls, 'fadeRange'), light.fadeRange); const c = light.color; glContext.uniform4f(glContext.getUniformLocation(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); } } /////////////////////////////////////////////////////////////////////////////// /** * A Light is an EngineObject that contributes a soft additive blob of color * to the LightSystem plugin's lightmap. * @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); 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; } /** 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); } }