Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/basementuniverse/lighting-2d
Lights and shadows in 2.5d scenes
https://github.com/basementuniverse/lighting-2d
Last synced: 23 days ago
JSON representation
Lights and shadows in 2.5d scenes
- Host: GitHub
- URL: https://github.com/basementuniverse/lighting-2d
- Owner: basementuniverse
- License: mit
- Created: 2024-10-13T19:31:56.000Z (3 months ago)
- Default Branch: master
- Last Pushed: 2024-10-15T23:09:01.000Z (3 months ago)
- Last Synced: 2024-10-17T08:48:06.910Z (3 months ago)
- Language: JavaScript
- Size: 2.12 MB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Lighting and shadows in 2.5d
This is an experiment showing how to create a 2.5d scene with lighting and shadows.
![Lighting and shadows in 2.5d](preview.png)
![Animated preview](preview-animated.gif)
(the above scene is in `/samples/sample-scene-1.json`)
## How to run it
1. Clone this repository
```bash
git clone https://github.com/basementuniverse/lighting-2d
```2. Install dependencies
```bash
npm install
```3. You'll need to run a local server to view the samples. You can use `http-server`:
```bash
npm install -g http-server
```4. Run the server
```bash
npm run start
```5. Open your browser and navigate to `http://localhost:8080`
6. To view the sample scene (the one in the preview image), click "Import state from JSON" (in the menu, top-right) and paste in the contents of `samples/sample-scene-1.json`
## How to integrate it into your game
A scene consists of shadow casters, shadow receivers, and lights.
First, we create a `LightingSystem`:
```ts
const lightingSystem: LightingSystem = new LightingSystem();
```(see `src/LightingSystem.ts` for options you can pass to the `LightingSystem`)
Implement the `CircleShadowCaster`, `RegionShadowCaster`, `SpriteShadowCaster`, `GroundShadowReceiver`, and `WallShadowReceiver` interfaces on your game object classes as appropriate.
If you want normal mapping and specular mapping, implement the `NormalMappable` and `SpecularMappable` interfaces as well.
Lights can be added to the lighting system like so:
```ts
lightingSystem.lights.push(new Light({
position: vec(100, 100),
radius: 100,
colour: 'white',
intensity: 0.5,
}));
```Update the lighting system every frame:
```ts
lightingSystem.update(dt);
```This will update each light.
Next, prepare the lightmap every frame (usually this should be inside the `draw()` function):
```ts
lightingSystem.drawSceneNormalMap(camera, normalMappables);
lightingSystem.drawSceneSpecularMap(camera, specularMappables);
lightingSystem.prepare(
camera,
groundShadowReceivers,
wallShadowReceivers,
regionShadowCasters,
spriteShadowCasters,
circleShadowCasters
);
```Render the scene as usual. Finally, overlay the lightmap onto the scene:
```ts
lightingSystem.draw(context);
```## How it works
> Note: the `vec` type is from [@basementuniverse/vec](https://www.npmjs.com/package/@basementuniverse/vec) and looks like:
> ```ts
> type vec = {
> x: number;
> y: number;
> }
> ```Shadow casters can be one of:
#### `RegionShadowCaster`
This is a rectangle that casts polygonal shadows.
* `id: string` a unique identifier
* `position: vec` the position of the shadow caster
* `size: vec` the size of the shadow caster (axis-aligned rectangle)
* `includeRegionShadow: boolean` when casting shadows, should we include the rectangular region of the shadow caster?e.g.
`includeRegionShadows = false` (default value)
`includeRegionShadows = true`
#### `SpriteShadowCaster`
This is a rectangle with a sprite name. The sprite will be rotated and stretched when casting shadows.
* `id: string` a unique identifier
* `position: vec` the position of the shadow caster
* `size: vec` the size of the shadow caster (axis-aligned rectangle), this will be used as the size of the sprite
* `shadowName: string` the name of the sprite used for casting shadows
* `anchor: vec` the anchor point of the sprite where (0, 0) is the top-left corner, (0.5, 0.5) is the center, (1, 1) is the bottom-right corner
* `offset: vec` used to offset the sprite from the anchor position
* `minShadowLength: number` the minimum length of the shadow
* `maxShadowLength: number` the maximum length of the shadowWe use a smoothstep interpolation to make the shadow length vary between `minShadowLength` and `maxShadowLength` based on the distance between the light and the `SpriteShadowCaster`.
#### `CircleShadowCaster`
This is a rectangle which casts an elliptical shadow.
* `id: string` a unique identifier
* `position: vec` the position of the shadow caster
* `size: vec` the size of the shadow caster (axis-aligned rectangle), this will be used as the size of the ellipse
* `anchor: vec` the anchor point of the circle where (0, 0) is the top-left corner, (0.5, 0.5) is the center, (1, 1) is the bottom-right corner
* `minShadowDistance: number` the minimum distance between the anchor position and center of the shadow ellipse
* `maxShadowDistance: number` the maximum distance between the anchor position and center of the shadow ellipseWe use a smoothstep interpolation to make the shadow distance vary between `minShadowDistance` and `maxShadowDistance` based on the distance between the light and the `CircleShadowCaster`.
---
Shadow receivers can be one of:
#### `GroundShadowReceiver`
This is a rectangle that receives ground shadows.
* `id: string` a unique identifier
* `position: vec` the position of the shadow receiver
* `size: vec` the size of the shadow receiver (axis-aligned rectangle)
* `colour: string` an optional background colour for the shadow receiver
* `spriteName: string` the name of the sprite used for rendering this shadow receiver (the sprite will be repeated)#### `WallShadowReceiver`
This is a rectangle or sprite that receives wall shadows.
* `id: string` a unique identifier
* `position: vec` the position of the shadow receiver
* `size: vec` the size of the shadow receiver (axis-aligned rectangle)
* `colour: string` an optional background colour for the shadow receiver
* `receiveLight: boolean` true if this shadow receiver should receive light and shadows (useful for tops of walls which should only receive ambient light)
* `spriteName: string` the name of the sprite used for rendering this shadow receiver
* `maskName: string` the name of a sprite used for masking the shadow receiver (optional)
* `spriteRepeat: boolean` true if the sprite should be repeated, false if the sprite should be stretched
* `layer: WallShadowLayer` either `WallShadowLayer.One` or `WallShadowLayer.Two`---
### Overview of `LightingSystem.prepare()` steps
1. Prepare each light separately
2. Prepare the ground mask
3. Prepare the wall mask (layer 1)
4. Prepare the wall mask (layer 2)
5. Prepare the ground-masked lightmap
6. Prepare the wall-masked lightmap (layer 1)
7. Prepare the wall-masked lightmap (layer 2)The 3 light maps (ground, wall layer 1, and wall layer 2) can now be combined to create the final light map. This is done inside `LightingSystem.draw()`.
#### 1. Prepare each light separately
##### 1.1. Prepare the base lightmap
First we prepare the base lightmap for the current light by drawing a radial gradient. We only do this if any of the light's parameters have changed.
##### 1.2. Prepare the ground lightmap
We draw the base lightmap onto the ground lightmap and then draw ground shadows for each shadow caster in range of the light.
##### 1.3. Prepare the wall lightmaps (both layers 1 and 2)
We draw the base lightmap onto each of the wall lightmaps.
Next, we prepare initial shading for each wall shadow receiver in range of the light. This means we shade the entire receiver based on the y-position of the light relative to the y-position of the receiver's bottom edge.
There's a small amount of margin when doing this, so that light can fade in gradually rather than snapping on/off.
Also note that wall lighting has a small amount of y-offset which helps to create a more realistic "pseudo-3d" lighting effect.
Finally, we project shadows onto the wall lightmaps. This is done by finding where ground shadows intersect the bottom edges of shadow receivers and drawing regions, scaled/rotated sprites, or ellipses onto the wall lightmaps (either layer 1 or 2 depending on the shadow caster's settings).
The lightmap for this light is now complete. We do this for each light in the scene.
#### 2. Prepare the ground mask
We draw all of the ground shadow receivers onto the ground mask.
Next, we subtract all of the wall shadow receivers (ignoring their layer settings) using the `destination-out` composite operation.
This is done using each receiver's `drawMask()` method.
#### 3. Prepare the wall mask (layer 1)
Similarly to the ground mask, we draw all of the wall shadow receivers for layer 1 onto the layer 1 wall mask, then subtract all of the layer 2 wall shadow receivers.
This is done using each receiver's `drawMask()` method.
#### 4. Prepare the wall mask (layer 2)
We draw all of the layer 2 wall shadow receivers onto the layer 2 wall mask.
This is done using each receiver's `drawMask()` method.
#### 5. Prepare the ground-masked lightmap
We fill the ground-masked lightmap canvas with the ambient light colour, then we draw each light's ground lightmap using the `screen` composite operation.
Finally, we mask the ground-masked lightmap using the ground mask and the `destination-atop` composite operation.
#### 6. Prepare the wall-masked lightmap (layer 1)
We do the same for the layer 1 wall lightmap...
#### 7. Prepare the wall-masked lightmap (layer 2)
...and again for the layer 2 wall lightmap.
---
When the masked lightmaps are combined, we get the final lightmap.
If we take the un-lit scene...
...and overlay the final lightmap using the `multiply` composite operation, we get the final scene with lighting and shadows.
### Why 2 wall layers?
Because some wall shadow receivers might overlap. When a shadow caster overlaps a wall shadow receiver, we skip rendering shadows from that caster onto the overlapping receiver (so that objects don't self-shadow / shadows are _behind_ the objects that cast them).
However, if there was just a single layer, then when 2 wall shadow receivers overlap they would be masked onto the same wall-mask. This can cause strange artefacts.
By separating objects which might overlap onto separate layers, we can avoid this issue.
As long as shadows aren't too large and we don't have too many objects overlapping (e.g. more than 2), then 2 layers should be enough.