diff --git a/docs/Phaser 4 Cone Lights/Phaser 4 Cone Lights.md b/docs/Phaser 4 Cone Lights/Phaser 4 Cone Lights.md new file mode 100644 index 0000000000..038bc8a095 --- /dev/null +++ b/docs/Phaser 4 Cone Lights/Phaser 4 Cone Lights.md @@ -0,0 +1,332 @@ +# Phaser 4 Cone Lights + +Cone lights are standard Phaser dynamic lights restricted to a directional cone. They are useful for flashlights, lantern beams, vision cones, headlights, searchlights, and other focal light sources. + +Cone lights run through the existing WebGL lighting shader. They do not require a mask, a second Camera, or rendering the map twice. Any Game Object that already works with Phaser lighting can be lit by a cone light. + +## Requirements + +Cone lights use the same requirements as regular dynamic lights: + +- The renderer must be WebGL. +- The Scene Light Manager must be enabled with `this.lights.enable()`. +- Game Objects that should receive light must use `setLighting(true)`. +- Normal maps are optional. Without a normal map, objects use the default flat normal map. +- Lighting has no effect in Canvas rendering. + +```javascript +function create () +{ + this.lights.enable(); + this.lights.setAmbientColor(0x202020); + + this.add.image(400, 300, 'tiles').setLighting(true); +} +``` + +## Creating a Cone Light + +The quickest way is `addConeLight`: + +```javascript +const light = this.lights.addConeLight( + 400, // x + 300, // y + 320, // radius + 0xffcc88, // color + 2, // intensity + Phaser.Math.DegToRad(0), // rotation + Phaser.Math.DegToRad(30), // inner angle + Phaser.Math.DegToRad(60) // outer angle +); +``` + +`rotation` is in radians. A rotation of `0` points right, `Math.PI / 2` points down in world space, `Math.PI` points left, and `-Math.PI / 2` points up. + +The cone angles are full cone widths, not half-angles: + +- `innerAngle`: the fully-lit cone width. +- `outerAngle`: the wider falloff cone width. + +Fragments inside the inner angle receive full light. Fragments between the inner and outer angles fade out smoothly. Fragments outside the outer angle receive no light from this light. + +If `outerAngle` is omitted, the cone uses a hard edge: + +```javascript +const hardCone = this.lights.addConeLight( + 400, + 300, + 320, + 0xffffff, + 1, + Phaser.Math.DegToRad(45), + Phaser.Math.DegToRad(35) +); +``` + +## Converting a Regular Light + +You can also create a normal radius light and restrict it later: + +```javascript +const light = this.lights.addLight(400, 300, 320, 0xffcc88, 2); + +light.setCone( + Phaser.Math.DegToRad(0), + Phaser.Math.DegToRad(30), + Phaser.Math.DegToRad(60) +); +``` + +This is useful when you want to reuse existing light setup code, or toggle between omnidirectional and cone modes. + +## Following a Player + +For a lantern or flashlight attached to a player, update the light position and cone rotation each frame: + +```javascript +function create () +{ + this.lights.enable(); + this.lights.setAmbientColor(0x080808); + + this.player = this.add.sprite(400, 300, 'player').setLighting(true); + + this.flashlight = this.lights.addConeLight( + this.player.x, + this.player.y, + 360, + 0xffd28a, + 2.5, + this.player.rotation, + Phaser.Math.DegToRad(35), + Phaser.Math.DegToRad(75) + ); +} + +function update () +{ + this.flashlight.x = this.player.x; + this.flashlight.y = this.player.y; + this.flashlight.setConeRotation(this.player.rotation); +} +``` + +If your character art points up by default, add an offset: + +```javascript +this.flashlight.setConeRotation(this.player.rotation - Math.PI / 2); +``` + +## Changing Cone Shape + +Use `setConeAngles` to adjust the width without changing direction: + +```javascript +light.setConeAngles( + Phaser.Math.DegToRad(20), + Phaser.Math.DegToRad(80) +); +``` + +For a narrow flashlight: + +```javascript +light.setConeAngles( + Phaser.Math.DegToRad(18), + Phaser.Math.DegToRad(35) +); +``` + +For a soft lantern beam: + +```javascript +light.setConeAngles( + Phaser.Math.DegToRad(60), + Phaser.Math.DegToRad(120) +); +``` + +For a hard-edged searchlight: + +```javascript +light.setConeAngles( + Phaser.Math.DegToRad(25), + Phaser.Math.DegToRad(25) +); +``` + +Angles are clamped to the range `0` to `Math.PI * 2`. If the outer angle is smaller than the inner angle, Phaser uses the inner angle for both. + +## Disabling the Cone + +Call `disableCone` to return a cone light to normal radius behavior: + +```javascript +light.disableCone(); +``` + +The previous cone values remain on the Light object, but they are ignored until `setCone` is called again. + +## API Summary + +### `this.lights.addConeLight` + +```javascript +this.lights.addConeLight( + x, + y, + radius, + rgb, + intensity, + rotation, + innerAngle, + outerAngle, + z +); +``` + +Parameters: + +- `x`: horizontal light position. Default `0`. +- `y`: vertical light position. Default `0`. +- `radius`: maximum light distance in pixels. Default `128`. +- `rgb`: light color as an integer RGB value. Default `0xffffff`. +- `intensity`: brightness multiplier. Default `1`. +- `rotation`: cone direction in radians. Default `0`. +- `innerAngle`: fully-lit cone width in radians. Default `Math.PI / 4`. +- `outerAngle`: outer falloff cone width in radians. Default `innerAngle`. +- `z`: light height. If omitted, Phaser uses `radius * 0.1`. + +Returns a `Phaser.GameObjects.Light`. + +### `light.setCone` + +```javascript +light.setCone(rotation, innerAngle, outerAngle); +``` + +Enables cone limiting and sets direction and angles. + +### `light.setConeRotation` + +```javascript +light.setConeRotation(rotation); +``` + +Changes the cone direction without changing the cone width. + +### `light.setConeAngles` + +```javascript +light.setConeAngles(innerAngle, outerAngle); +``` + +Changes the cone width without changing the cone direction. + +### `light.disableCone` + +```javascript +light.disableCone(); +``` + +Disables cone limiting and returns the light to normal radius behavior. + +## Performance + +Cone lights are cheaper than a mask-based approach that renders the map through extra Cameras. They add a small amount of shader math to the existing lighting pass for each active cone light. + +The normal lighting limits still apply: + +- The renderer processes up to the configured `maxLights`. +- Lights outside the Camera view are culled. +- If too many lights are visible, Phaser keeps the closest lights to the Camera. +- Lighting changes the shader path, which can break batches. + +For best performance: + +- Keep the number of active cone lights low. +- Use ambient color for general darkness instead of many large low-intensity lights. +- Prefer one player cone light plus a few local radius lights over many overlapping large cones. +- Reduce `radius` where possible; it affects culling and how many fragments evaluate visible light. + +## Choosing Between Cone Light and Point Light + +Use cone lights when the light should affect lit Game Objects and normal maps. + +Use Point Light Game Objects when you only need a fast radial glow effect. Point Lights do not affect other Game Objects and do not use normal maps. + +For a lantern that should illuminate the map, player, enemies, or normal-mapped tiles, use `addConeLight` or `addLight().setCone(...)`. + +## Complete Example + +```javascript +class DungeonScene extends Phaser.Scene +{ + preload () + { + this.load.image('floor', 'assets/floor.png'); + this.load.image('floor_n', 'assets/floor_n.png'); + this.load.image('player', 'assets/player.png'); + this.load.image('player_n', 'assets/player_n.png'); + } + + create () + { + this.lights.enable(); + this.lights.setAmbientColor(0x050505); + + const floor = this.add.image(400, 300, 'floor'); + floor.setLighting(true); + + this.player = this.add.sprite(400, 300, 'player'); + this.player.setLighting(true); + + this.lantern = this.lights.addConeLight( + this.player.x, + this.player.y, + 420, + 0xffcc88, + 2.2, + 0, + Phaser.Math.DegToRad(55), + Phaser.Math.DegToRad(110), + 42 + ); + + this.cursors = this.input.keyboard.createCursorKeys(); + } + + update () + { + const speed = 3; + + if (this.cursors.left.isDown) + { + this.player.x -= speed; + this.player.rotation = Math.PI; + } + else if (this.cursors.right.isDown) + { + this.player.x += speed; + this.player.rotation = 0; + } + + if (this.cursors.up.isDown) + { + this.player.y -= speed; + this.player.rotation = -Math.PI / 2; + } + else if (this.cursors.down.isDown) + { + this.player.y += speed; + this.player.rotation = Math.PI / 2; + } + + this.lantern.x = this.player.x; + this.lantern.y = this.player.y; + this.lantern.setConeRotation(this.player.rotation); + } +} +``` + diff --git a/docs/README.md b/docs/README.md index 233c26d77b..1a2da5a4f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,3 +3,7 @@ This folder contains articles and guides describing various aspects of Phaser 4 rendering in depth. For the full API documentation please see [docs.phaser.io](https://docs.phaser.io) + +## Guides + +- [Phaser 4 Cone Lights](./Phaser%204%20Cone%20Lights/Phaser%204%20Cone%20Lights.md) diff --git a/src/gameobjects/lights/Light.js b/src/gameobjects/lights/Light.js index d828ce569d..9eb2c42798 100644 --- a/src/gameobjects/lights/Light.js +++ b/src/gameobjects/lights/Light.js @@ -100,6 +100,46 @@ var Light = new Class({ */ this.z = z === undefined ? radius * 0.1 : z; + /** + * Whether this Light is restricted to a cone. + * + * @name Phaser.GameObjects.Light#coneEnabled + * @type {boolean} + * @default false + * @since 4.2.0 + */ + this.coneEnabled = false; + + /** + * The cone direction, in radians, in world space. + * + * @name Phaser.GameObjects.Light#coneRotation + * @type {number} + * @default 0 + * @since 4.2.0 + */ + this.coneRotation = 0; + + /** + * The inner cone angle, in radians. Fragments inside this angle receive full light. + * + * @name Phaser.GameObjects.Light#coneInnerAngle + * @type {number} + * @default 0 + * @since 4.2.0 + */ + this.coneInnerAngle = 0; + + /** + * The outer cone angle, in radians. Fragments outside this angle receive no light. + * + * @name Phaser.GameObjects.Light#coneOuterAngle + * @type {number} + * @default 0 + * @since 4.2.0 + */ + this.coneOuterAngle = 0; + /** * The flags that are compared against `RENDER_MASK` to determine if this Light will render or not. * The relevant bit is 0001, set by the Visible component. The remaining bits are unused by Light @@ -347,6 +387,103 @@ var Light = new Class({ { this.z = z * this.radius; + return this; + }, + + /** + * Restrict this Light to a cone, suitable for flashlights, lanterns and other focal lights. + * + * The `rotation` is in radians, where 0 points to the right in world space. The `innerAngle` + * is the fully-lit cone width. The `outerAngle` is the wider falloff cone width; if omitted, + * the cone has a hard edge. Both angles are full cone widths, not half-angles. + * + * @method Phaser.GameObjects.Light#setCone + * @since 4.2.0 + * + * @param {number} rotation - The direction of the cone, in radians. + * @param {number} innerAngle - The fully-lit cone width, in radians. + * @param {number} [outerAngle=innerAngle] - The outer falloff cone width, in radians. + * + * @return {this} This Light object. + */ + setCone: function (rotation, innerAngle, outerAngle) + { + if (outerAngle === undefined) { outerAngle = innerAngle; } + + innerAngle = Math.max(0, Math.min(Math.PI * 2, innerAngle)); + outerAngle = Math.max(0, Math.min(Math.PI * 2, outerAngle)); + + if (outerAngle < innerAngle) + { + outerAngle = innerAngle; + } + + this.coneEnabled = true; + this.coneRotation = rotation; + this.coneInnerAngle = innerAngle; + this.coneOuterAngle = outerAngle; + + return this; + }, + + /** + * Set the direction of this Light cone, in radians. + * + * @method Phaser.GameObjects.Light#setConeRotation + * @since 4.2.0 + * + * @param {number} rotation - The direction of the cone, in radians. + * + * @return {this} This Light object. + */ + setConeRotation: function (rotation) + { + this.coneRotation = rotation; + + return this; + }, + + /** + * Set the inner and outer cone angles, in radians. + * + * @method Phaser.GameObjects.Light#setConeAngles + * @since 4.2.0 + * + * @param {number} innerAngle - The fully-lit cone width, in radians. + * @param {number} [outerAngle=innerAngle] - The outer falloff cone width, in radians. + * + * @return {this} This Light object. + */ + setConeAngles: function (innerAngle, outerAngle) + { + if (outerAngle === undefined) { outerAngle = innerAngle; } + + innerAngle = Math.max(0, Math.min(Math.PI * 2, innerAngle)); + outerAngle = Math.max(0, Math.min(Math.PI * 2, outerAngle)); + + if (outerAngle < innerAngle) + { + outerAngle = innerAngle; + } + + this.coneInnerAngle = innerAngle; + this.coneOuterAngle = outerAngle; + + return this; + }, + + /** + * Disable cone limiting and make this Light omnidirectional again. + * + * @method Phaser.GameObjects.Light#disableCone + * @since 4.2.0 + * + * @return {this} This Light object. + */ + disableCone: function () + { + this.coneEnabled = false; + return this; } diff --git a/src/gameobjects/lights/LightsManager.js b/src/gameobjects/lights/LightsManager.js index 3ea2aba980..e417074616 100644 --- a/src/gameobjects/lights/LightsManager.js +++ b/src/gameobjects/lights/LightsManager.js @@ -334,6 +334,36 @@ var LightsManager = new Class({ return light; }, + /** + * Creates a new cone-limited {@link Phaser.GameObjects.Light} object, adds it to this Lights Manager, + * and returns it. + * + * The cone angles are full cone widths in radians. Fragments inside `innerAngle` receive full light, + * and fragments between `innerAngle` and `outerAngle` are softly attenuated. + * + * @method Phaser.GameObjects.LightsManager#addConeLight + * @since 4.2.0 + * + * @param {number} [x=0] - The horizontal position of the Light. + * @param {number} [y=0] - The vertical position of the Light. + * @param {number} [radius=128] - The radius of the Light. + * @param {number} [rgb=0xffffff] - The integer RGB color of the light. + * @param {number} [intensity=1] - The intensity of the Light. + * @param {number} [rotation=0] - The direction of the cone, in radians. + * @param {number} [innerAngle=Math.PI / 4] - The fully-lit cone width, in radians. + * @param {number} [outerAngle=innerAngle] - The outer falloff cone width, in radians. + * @param {number} [z] - The z position of the light. If omitted, it will be set to `radius * 0.1`. + * + * @return {Phaser.GameObjects.Light} The Light that was added. + */ + addConeLight: function (x, y, radius, rgb, intensity, rotation, innerAngle, outerAngle, z) + { + if (rotation === undefined) { rotation = 0; } + if (innerAngle === undefined) { innerAngle = Math.PI / 4; } + + return this.addLight(x, y, radius, rgb, intensity, z).setCone(rotation, innerAngle, outerAngle); + }, + /** * Removes a {@link Phaser.GameObjects.Light} from this Lights Manager. The Light will no longer * influence the rendering of any Game Objects. The Light object itself is not destroyed; it is diff --git a/src/renderer/webgl/Utils.js b/src/renderer/webgl/Utils.js index 69c88a2298..8c0cf615c4 100644 --- a/src/renderer/webgl/Utils.js +++ b/src/renderer/webgl/Utils.js @@ -135,9 +135,9 @@ module.exports = { * * When `enable` is `true`, this function queries the Scene's Light Manager for all * active lights visible to the camera and uploads their positions, colors, intensities, - * and radii as uniform arrays to the shader. It also uploads the ambient light color, - * camera transform, and the normal map texture unit. Optionally enables self-shadowing - * by uploading the penumbra and diffuse threshold uniforms. + * radii, and cone settings as uniform arrays to the shader. It also uploads the ambient + * light color, camera transform, and the normal map texture unit. Optionally enables + * self-shadowing by uploading the penumbra and diffuse threshold uniforms. * * When `enable` is `false`, all previously set lighting uniforms are removed from the * program manager. If the Scene has no active Light Manager, the function returns early @@ -231,11 +231,14 @@ module.exports = { vec ); + var lightX = vec.x; + var lightY = height - vec.y; + programManager.setUniform( lightName + 'position', [ - vec.x, - height - (vec.y), + lightX, + lightY, light.z * camera.zoom ] ); @@ -255,6 +258,64 @@ module.exports = { lightName + 'radius', light.radius ); + + if (light.coneEnabled) + { + camMatrix.transformPoint( + light.x + Math.cos(light.coneRotation), + light.y + Math.sin(light.coneRotation), + vec + ); + + var dirX = vec.x - lightX; + var dirY = (height - vec.y) - lightY; + var dirLength = Math.sqrt(dirX * dirX + dirY * dirY); + + if (dirLength === 0) + { + dirX = 1; + dirY = 0; + } + else + { + dirX /= dirLength; + dirY /= dirLength; + } + + programManager.setUniform( + lightName + 'direction', + [ + dirX, + dirY + ] + ); + programManager.setUniform( + lightName + 'cone', + [ + Math.cos(light.coneOuterAngle * 0.5), + Math.cos(light.coneInnerAngle * 0.5), + 1 + ] + ); + } + else + { + programManager.setUniform( + lightName + 'direction', + [ + 1, + 0 + ] + ); + programManager.setUniform( + lightName + 'cone', + [ + -1, + 1, + 0 + ] + ); + } } if (selfShadow) @@ -290,6 +351,8 @@ module.exports = { programManager.removeUniform(lightName + 'color'); programManager.removeUniform(lightName + 'intensity'); programManager.removeUniform(lightName + 'radius'); + programManager.removeUniform(lightName + 'direction'); + programManager.removeUniform(lightName + 'cone'); } } } diff --git a/src/renderer/webgl/shaders/DefineLights-glsl.js b/src/renderer/webgl/shaders/DefineLights-glsl.js index 7cf3edc8e0..ee9826b29c 100644 --- a/src/renderer/webgl/shaders/DefineLights-glsl.js +++ b/src/renderer/webgl/shaders/DefineLights-glsl.js @@ -5,6 +5,8 @@ module.exports = [ ' vec3 color;', ' float intensity;', ' float radius;', + ' vec2 direction;', + ' vec3 cone; // outer cosine, inner cosine, enabled flag', '};', 'const int kMaxLights = LIGHT_COUNT;', 'uniform vec4 uCamera; /* x, y, rotation, zoom */', @@ -35,6 +37,16 @@ module.exports = [ ' float diffuseFactor = max(dot(normal, lightNormal), 0.0);', ' float radius = (light.radius / res.x * uCamera.w) * uCamera.w;', ' float attenuation = clamp(1.0 - distToSurf * distToSurf / (radius * radius), 0.0, 1.0);', + ' if (light.cone.z > 0.5)', + ' {', + ' vec2 coneVector = gl_FragCoord.xy - light.position.xy;', + ' float coneLength = length(coneVector);', + ' vec2 lightToFrag = coneLength > 0.0 ? coneVector / coneLength : light.direction;', + ' float coneDot = dot(lightToFrag, light.direction);', + ' float coneRange = abs(light.cone.y - light.cone.x);', + ' float coneAttenuation = coneRange < 0.0001 ? step(light.cone.y, coneDot) : smoothstep(light.cone.x, light.cone.y, coneDot);', + ' attenuation *= coneAttenuation;', + ' }', ' #ifdef FEATURE_SELFSHADOW', ' float occluded = smoothstep(0.0, 1.0, (diffuseFactor - occlusionThreshold) / uPenumbra);', ' vec3 diffuse = light.color * diffuseFactor * occluded;', diff --git a/src/renderer/webgl/shaders/src/DefineLights.glsl b/src/renderer/webgl/shaders/src/DefineLights.glsl index 33ce4f866a..af39f2503a 100644 --- a/src/renderer/webgl/shaders/src/DefineLights.glsl +++ b/src/renderer/webgl/shaders/src/DefineLights.glsl @@ -4,6 +4,8 @@ struct Light vec3 color; float intensity; float radius; + vec2 direction; + vec3 cone; // outer cosine, inner cosine, enabled flag }; // #define LIGHT_COUNT N @@ -40,6 +42,16 @@ vec4 getLighting (vec4 fragColor, vec3 normal) float diffuseFactor = max(dot(normal, lightNormal), 0.0); float radius = (light.radius / res.x * uCamera.w) * uCamera.w; float attenuation = clamp(1.0 - distToSurf * distToSurf / (radius * radius), 0.0, 1.0); + if (light.cone.z > 0.5) + { + vec2 coneVector = gl_FragCoord.xy - light.position.xy; + float coneLength = length(coneVector); + vec2 lightToFrag = coneLength > 0.0 ? coneVector / coneLength : light.direction; + float coneDot = dot(lightToFrag, light.direction); + float coneRange = abs(light.cone.y - light.cone.x); + float coneAttenuation = coneRange < 0.0001 ? step(light.cone.y, coneDot) : smoothstep(light.cone.x, light.cone.y, coneDot); + attenuation *= coneAttenuation; + } #ifdef FEATURE_SELFSHADOW float occluded = smoothstep(0.0, 1.0, (diffuseFactor - occlusionThreshold) / uPenumbra); vec3 diffuse = light.color * diffuseFactor * occluded; @@ -53,4 +65,4 @@ vec4 getLighting (vec4 fragColor, vec3 normal) vec4 colorOutput = vec4(uAmbientLightColor + finalColor, 1.0); return colorOutput; -} \ No newline at end of file +} diff --git a/tests/renderer/webgl/Utils.test.js b/tests/renderer/webgl/Utils.test.js index c894d408dd..0d54747446 100644 --- a/tests/renderer/webgl/Utils.test.js +++ b/tests/renderer/webgl/Utils.test.js @@ -529,5 +529,121 @@ describe('Phaser.Renderer.WebGL.Utils.getTintFromFloats', function () expect(setUniformCalls.indexOf('uDiffuseFlatThreshold')).toBe(-1); expect(setUniformCalls.indexOf('uPenumbra')).toBe(-1); }); + + it('should upload disabled cone uniforms for normal radius lights', function () + { + var programManager = { + setUniform: vi.fn(), + removeUniform: vi.fn() + }; + + var light = { + x: 100, + y: 150, + z: 10, + scrollFactorX: 1, + scrollFactorY: 1, + color: { r: 1, g: 0.5, b: 0.25 }, + intensity: 2, + radius: 256, + coneEnabled: false + }; + + var drawingContext = { + camera: { + scene: { + sys: { + lights: { + active: true, + getLights: function () { return [ { light: light } ]; }, + ambientColor: { r: 0, g: 0, b: 0 } + } + } + }, + x: 0, + y: 0, + rotation: 0, + zoom: 1, + scrollX: 0, + scrollY: 0, + matrixCombined: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 } + }, + height: 600 + }; + + var vec = { x: 0, y: 0 }; + + Utils.updateLightingUniforms(true, drawingContext, programManager, 1, vec, false, 0, 0); + + expect(programManager.setUniform).toHaveBeenCalledWith('uLights[0].position', [100, 450, 10]); + expect(programManager.setUniform).toHaveBeenCalledWith('uLights[0].direction', [1, 0]); + expect(programManager.setUniform).toHaveBeenCalledWith('uLights[0].cone', [-1, 1, 0]); + }); + + it('should upload cone direction and cutoffs for cone lights', function () + { + var programManager = { + setUniform: vi.fn(), + removeUniform: vi.fn() + }; + + var light = { + x: 100, + y: 150, + z: 10, + scrollFactorX: 1, + scrollFactorY: 1, + color: { r: 1, g: 1, b: 1 }, + intensity: 1, + radius: 128, + coneEnabled: true, + coneRotation: Math.PI / 2, + coneInnerAngle: Math.PI / 4, + coneOuterAngle: Math.PI / 2 + }; + + var drawingContext = { + camera: { + scene: { + sys: { + lights: { + active: true, + getLights: function () { return [ { light: light } ]; }, + ambientColor: { r: 0, g: 0, b: 0 } + } + } + }, + x: 0, + y: 0, + rotation: 0, + zoom: 2, + scrollX: 0, + scrollY: 0, + matrixCombined: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 } + }, + height: 600 + }; + + var vec = { x: 0, y: 0 }; + + Utils.updateLightingUniforms(true, drawingContext, programManager, 1, vec, false, 0, 0); + + var directionCall = programManager.setUniform.mock.calls.find(function (call) + { + return call[0] === 'uLights[0].direction'; + }); + + expect(programManager.setUniform).toHaveBeenCalledWith('uLights[0].position', [100, 450, 20]); + expect(directionCall[1][0]).toBeCloseTo(0); + expect(directionCall[1][1]).toBeCloseTo(-1); + expect(programManager.setUniform).toHaveBeenCalledWith( + 'uLights[0].cone', + [ + Math.cos(Math.PI / 4), + Math.cos(Math.PI / 8), + 1 + ] + ); + }); }); });