DnD Map Editor - Lighting

Part 2 of infinity.

Introduction

I’ve been working on a long-term project and I haven’t had anything interesting to show regarding it. Actually, I think I got a little stuck on it and didn’t spent enough time doing other things, like learning, and, uh, writing this blog. Oops.

So, in an effort to diversify my time, and also because the last session of my DnD campaign took place in a poorly-lit area, I spent some time adding lighting effects to my map editor.

Light in DnD

There are various ways to provide light in DnD - you can have regular torches/candles/lanterns, but also various spells that magically give off light. Either way, most light sources canonically give off “bright light” in a certain radius and “dim light” to a further radius (usually double the first). For example, a standard torch is 20 ft. of bright light, 40 ft. of dim light.

The players can only see things that are in one of these two circles (and in the outer one those things have “concealment”, meaning you have a 20% chance to miss them if you try to attack them, mostly). So, when displaying the map, I should avoid drawing any NPCs outside of the light, and I should also make it obvious to them where the boundaries are. And naturally I shouldn’t draw the background or walls or anything outside of the lit area - it should just be a big black void.

To add one more hitch, my party had a pair of goggles that give them “darkvision”, which means they can see in the dark, but they can’t see color. I didn’t plan on getting so fancy as to desaturate everything, but I did want to at least indicate which area was “darkvision” as opposed to normal light.

Drawing more stuff on the canvas

The existing editor is just drawing stuff to a canvas. Everything is effectively layered based on the order it is drawn - latest stuff on top - which is how I was drawing the existing order of things, from back to front:

  1. Background color
  2. Painted areas (used to indicate traps, fire, water, etc. - I haven’t bothered to make complex sprites yet)
  3. Any complex background image (okay, I was partially lying above, I have made a couple of “magic circle” sprites and I just temporarily put them in a “DrawBackground()” function each session I wanted to use them)
  4. Fog-of-war (anything I manually obscured, like the inside of a building)
  5. Entities (drawn as the first letter of their name, or for e.g. five skeletons, “S1”, “S2”, etc.)
  6. The grid lines
  7. Walls (or other lines around the edges of squares)
  8. An indicator in the bottom-right that says what level they’re on (if they’re in a multi-story building or something)

You may note that fog-of-war seems like it should obscure entities and walls, and it does, even though it’s drawn first - in the methods for drawing walls/entities I manually check and make sure they’re not obscured by the fog of war.

My intuition was to insert a “light/darkness” layer after the fog-of-war layer, but have similar logic in entities/walls to remove them if they weren’t close enough to the light.

How to actually draw lights

There are several specific problems involved in drawing light/shadow areas on a map.

First, unless your map is a blank white void, you can’t just draw black over all of it and then add a white circle. You’ll be painting over everything that you wanted to show in the lit area:

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle = '#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.strokeStyle = '#000000';
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, 400, 400);

ctx.fillStyle = '#FFFFFF';

ctx.beginPath();
ctx.moveTo(200, 200);
ctx.arc(200, 200, 100, 0, Math.PI * 2);
ctx.fill();

White circle against black background

(“I promise there’s a red line behind all of that. Don’t argue with the DM.")

Really we want to draw a black area, with a circle cut out of it. For this we need to change the globalCompositeOperation on the canvas. This tells it how to merge newly-painted stuff into the existing image. There’s a bunch of options, but in this case we want “destination-out”, which tells it to draw the existing content (the “destination”) but taking out the area covered by the new content (the “source”). I will admit I find the names a little confusing, so just look at the examples at that link if you’re curious about other options.

So, let’s change that:

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle = '#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.strokeStyle = '#000000';
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, 400, 400);

ctx.globalCompositeOperation = 'destination-out';

ctx.fillStyle = '#FFFFFF';

ctx.beginPath();
ctx.moveTo(200, 200);
ctx.arc(200, 200, 100, 0, Math.PI * 2);
ctx.fill();

White circle against black background

And, voila - we paint the exact same thing. Well, sort of. Technically it did paint a black rectangle with an area taken out, rather than a black rectangle with a white circle in it. But the problem is we already painted over the red line that I super promise is there when we made the black rectangle.

So what we actually need to do is draw this whole black/white combo to another canvas entirely, then when it’s good and ready and the circle is definitely transparent rather than white, paint it over everything:

let overlayCanvas = document.createElement('canvas');
overlayCanvas.width = 400;
overlayCanvas.height = 400;

let overlayCtx = overlayCanvas.getContext('2d');

overlayCtx.strokeStyle = '#000000';
overlayCtx.fillStyle = '#000000';
overlayCtx.fillRect(0, 0, 400, 400);

overlayCtx.globalCompositeOperation = 'destination-out';

overlayCtx.fillStyle = '#FFFFFF';

overlayCtx.beginPath();
overlayCtx.moveTo(200, 200);
overlayCtx.arc(200, 200, 100, 0, Math.PI * 2);
overlayCtx.fill();

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle='#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.drawImage(overlayCanvas, 0, 0);

White circle with red line in it, against black background

Okay. There’s the line I promised you. So this is much better.

Although, I don’t like the hard edges on the circle, so the next step is to make them fade out. Luckily the whole globalCompositeOperation/“destination-out” thing is very aware of the alpha channel, so as long as I make it so I’m “drawing” (or rather omitting) a white circle that fades out, the part that gets taken out of the black background will itself fade out.

let overlayCanvas = document.createElement('canvas');
overlayCanvas.width = 400;
overlayCanvas.height = 400;

let overlayCtx = overlayCanvas.getContext('2d');

overlayCtx.strokeStyle = '#000000';
overlayCtx.fillStyle = '#000000';
overlayCtx.fillRect(0, 0, 400, 400);

overlayCtx.globalCompositeOperation = 'destination-out';

let gradient = overlayCtx.createRadialGradient(200, 200, 0, 200, 200, 100);

gradient.addColorStop(0, 'rgba(0, 0, 0, 1.0)');
gradient.addColorStop(0.9, 'rgba(0, 0, 0, 1.0)');
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.0)');

overlayCtx.fillStyle = gradient;
overlayCtx.beginPath();
overlayCtx.moveTo(200, 200);
overlayCtx.arc(200, 200, 100, 0, Math.PI * 2);
overlayCtx.fill();

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle = '#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.drawImage(overlayCanvas, 0, 0);

White circle with faded edges and a red line in it, against black background

Note that it doesn’t really matter what color I “draw” the circle as - since it’s just taking it out of whatever’s already painted to the canvas, only the alpha channel matters.

Now, this takes us most of the way there. But, I said before I want to have areas of “dim light” and “bright light”, and the way the rules work, multiple sources of “dim light” do not add up to an area of “bright light”.

Making a single area of “dim light” is pretty easy - I can just make the whole circle have an alpha value less than 1.0. Making them stack up differently, though, gets tricky. Naively you might try this:

function drawCircle(x, y, radius, alpha, ctx) {
    let gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
    gradient.addColorStop(0, `rgba(0, 0, 0, ${alpha})`);
    gradient.addColorStop(0.9, `rgba(0, 0, 0, ${alpha})`);
    gradient.addColorStop(1, 'rgba(0, 0, 0, 0.0)');
    
    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fill();
}

let overlayCanvas = document.createElement('canvas');
overlayCanvas.width = 400;
overlayCanvas.height = 400;

let overlayCtx = overlayCanvas.getContext('2d');

overlayCtx.strokeStyle = '#000000';
overlayCtx.fillStyle = '#000000';
overlayCtx.fillRect(0, 0, 400, 400);

overlayCtx.globalCompositeOperation = 'destination-out';

drawCircle(150, 200, 100, 0.5, overlayCtx);
drawCircle(250, 200, 100, 0.5, overlayCtx);

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle = '#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.drawImage(overlayCanvas, 0, 0);

Overlapping circles - the portion of overlap is brighter than either

Great for Venn diagrams, not so much for what I’m trying to do.

A solution to this is to make the circles actually have alpha values of 1.0, but when we paint the overlay, set the global alpha value to a lower number on the canvas:

drawCircle(150, 200, 100, 1.0, overlayCtx);
drawCircle(250, 200, 100, 1.0, overlayCtx);

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle = '#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.globalAlpha = 0.5;
ctx.drawImage(overlayCanvas, 0, 0);
ctx.globalAlpha = 1.0;

Overlapping circles that merge rather than adding, but now the black background is faded

So this has the side problem that now we made the black overlay itself faded. So what we really need to do is make one canvas where we paint the light area - with an alpha of 1.0 - then another canvas where we paint the black area, then, with the global alpha set to 0.5 or whatever, paint the first canvas onto it, then, paint that canvas onto the main one:

let lightCanvas = document.createElement('canvas');
lightCanvas.width = 400;
lightCanvas.height = 400;

let lightCtx = lightCanvas.getContext('2d');

drawCircle(150, 200, 100, 1.0, lightCtx);
drawCircle(250, 200, 100, 1.0, lightCtx);

let overlayCanvas = document.createElement('canvas');
overlayCanvas.width = 400;
overlayCanvas.height = 400;

let overlayCtx = overlayCanvas.getContext('2d');

overlayCtx.strokeStyle = '#000000';
overlayCtx.fillStyle = '#000000';
overlayCtx.fillRect(0, 0, 400, 400);

overlayCtx.globalCompositeOperation = 'destination-out';
overlayCtx.globalAlpha = 0.5;
overlayCtx.drawImage(lightCanvas, 0, 0);

let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d');

ctx.fillStyle = '#FF4444';
ctx.fillRect(0, 190, 400, 20);

ctx.drawImage(overlayCanvas, 0, 0);

Overlapping circles that merge rather than adding, but now the black background is faded

Finally, the desired effect!

Now, I’ve already pasted way too much code into this post, so I’ll just give a quick explanation for the rest. As I said, there’s also areas of “bright light”. In this case you can just repeat the above process of drawing the “bright light” circles with globalCompositeOperation set to “destination-out”. Technically you don’t even need any more canvas layers - it’ll erase darkness or partial darkness equally well.

The same holds true for the “darkvision”, except I did that after “dim light” and before “bright light” and also drew a transparent blue circle over it (with the normal globalCompositeOperation of “source-over” on that part). This means that if “darkvision” and “dim light” overlap, you’ll just see the “darkvision” effect, but if “darkvision” and “bright light” overlap, you’ll just see the “bright light” one.

What I Learned From This

Mostly that lighting is complicated! I’m sure that’s not news at all to anybody who regularly works on images or graphics editors or in video games or anything like that, but as somebody who does none of those things, it took me several hours to get all of this working properly.

The “globalCompositeOperation” attribute was itself totally new to me (as was “globalAlpha” but that one isn’t really as much of a puzzler), as was using one canvas in a “drawImage” call on another canvas’s context. (Side note, it seems weird that you can’t use the context as an argument for this function, but it’s not super important.)

I hadn’t used radial gradients before, although they weren’t too bad, other than the fact that you have to specify the center of the radial gradient with respect to the overall image, which means you can’t reuse the same radial gradient for two circles, which seems counter-intuitive to me.

Remaining Issues

Embarassingly, I have yet to make the light actually obey walls. This particular session pretty much saw the party travelling through the Kansas of the Negative Energy Plane, so really it was just a big, featureless void. I am intending to do that soonish (probably whenever I urgently need it for that weekend’s session), but off the top of my head I’m not entirely sure how to cut off an arc at a certain point. Maybe I’ll technically have to draw separate chunks of the arc? I dunno. Stay tuned for a blog post about that, I guess.


Timothy Bond

1941 Words

2020-08-03 09:00 -0700