This update brings cel-shading to add volume and depth.

Dynamic shadows

Shading’s most easily done with underlying 3D models, as there are many libraries like Three.js that can render a scene with proper lighting “for free”. The problem is that this requires procedural generation of meshes from parameters. Generating this mapping would be a magnitude harder than going from parameters (which are 1D by nature) to 2D scaling.

This is a major concern because users of DAD are expected to create their own assets (including the parameter to drawing mapping for new body parts).

Thus, switching to a 3D underlying model is not feasible.

Instead, we can settle for the illusion of 3D. The strength of a 2D system is the easy mapping from parameters to visual effects, and a natural way to extend 2D is to add layers of 2D. Since we’re only drawing to each layer at a time, we can think in 2D while getting some effects of 3D. We achieve this through multiple layers of canvases, each with a different z-index.

For each drawing layer, we have an associated shading layer:

    const Layer = Object.freeze({
        BASE              : 0,
        BACK              : 1,
        FRONT             : 2,
        SHADING_FRONT     : 3,
        ARMS              : 4,
        SHADING_ARMS      : 5,
        GENITALS          : 6,
        SHADING_GENITALS  : 7,
        BELOW_HAIR        : 8,
        SHADING_BELOW_HAIR: 9,
        HAIR              : 10,
        SHADING_HAIR      : 11,
        EFFECTS           : 12,
        NUM_LAYERS        : 13
    });

    const ShadingLayers = [
        Layer.SHADING_FRONT,
        Layer.SHADING_ARMS,
        Layer.SHADING_GENITALS,
        Layer.SHADING_BELOW_HAIR,
        Layer.SHADING_HAIR
    ];

The idea is that shadows drawn on any layer should apply on all drawing layers below it. One easy way to do this is actually to not have separate shading layers and treat shading parts equally as body parts, with a transparent fill. This doesn’t work so well because if you have overlapping shading parts, the overlapping portion gets darkened. This might seem like expected behaviour, but you have to consider the difficulty of defining shading areas without some overlap.

Instead, we can use a solid shading colour and rely on blending modes. Multiply is useful here, which lets us darken the destination canvas.

Another problem is that if we blend multiply onto a transparent region, that region becomes the solid shadow colour rather than remaining transparent. Shadows need a medium to exist on, so having “floating” shadows that look like grey blobs makes no sense.

To solve this, we need the destination canvas (the one with body parts drawn) to be the clipping mask for itself. There is actually a composition mode for this - “source-atop”, but unfortunately we cannot apply multiple composition mode together. However, an equivalent operation is to draw the destination canvas to the shading canvas first (did you read that right?) using “destination-in” composition mode.

The steps are:

  • draw body parts canvas onto shading canvas using “destination-in”, this clips the shading to just the body parts
  • draw shading canvas onto body parts canvas using “multiply”, this darkens shading regions

Thus the code looks something like:

        const shadingCanvas = document.createElement("canvas");
        shadingCanvas.width = ex.canvasGroup[0].width;
        shadingCanvas.height = ex.canvasGroup[0].height;
        const shadingCtx = shadingCanvas.getContext("2d");

        // merge down the shading layers
        da.ShadingLayers.forEach((shadingLayer) => {

            for (let layer = shadingLayer - 1; layer > 0; --layer) {
                // apply shading to each parts layer below current one
                if (da.ShadingLayers.indexOf(layer) > -1) {
                    continue;
                }
                // clear canvas and apply shading
                shadingCtx.clearRect(0, 0, shadingCanvas.width, shadingCanvas.height);
                shadingCtx.drawImage(ex.canvasGroup[shadingLayer], 0, 0);
                // clip to just this base layer
                shadingCtx.globalCompositeOperation = "destination-in";
                shadingCtx.drawImage(ex.canvasGroup[layer], 0, 0);

                const baseCtx = ctxGroup[layer];
                baseCtx.globalCompositeOperation = "multiply";
                baseCtx.setTransform(1, 0, 0, 1, 0, 0);
                baseCtx.drawImage(shadingCanvas, 0, 0);

                baseCtx.globalCompositeOperation = "source-over";
                shadingCtx.globalCompositeOperation = "source-over";
            }
        });

Since we have to apply the same shading to all body part layers under it, we can’t destructively draw to the shading canvas. Instead, we use a temporary canvas that’s initialized to the shading canvas for each drawing layer.