Kaleidoscope

Technically, this is still about DnD.

Introduction

In an effort to keep things fresh in my DnD campaign, I recently forced the players into a dungeon crawl in a giant tower full of weird stuff. One of the weird things was basically an escape room, which I constructed by making four websites that represented the four specific artifacts they needed to use together to solve the puzzle. I might talk a little more about the rest of the room at one point, but the part I’m most proud of - and was most technically interesting - was a sort of magic kaleidoscope that (mostly) worked like a real one would.

Intro to Kaleidoscopes

I initially assumed that most of my readers will know what a kaleidoscope is, but sometimes things are weirdly cultural or regional in a way that’s hard to predict or recognize from the inside, like Coney dogs or puppy chow. Also, if you somehow found this blog without knowing me personally (something I very much doubt at the time of writing), you now know that I’m from the Midwest.

Anyway, just in case, a kaleidoscope is basically just a little tube with some colored glass at one end and some kind of internal mirrors that produce a repeating pattern when you look into the other end. You can turn it to fluidly change the patterns. I feel like they’re the sort of thing that was probably a lot more popular before we had video games, although I bet they’re also really popular with adults on psychedelics.

Technically you can create lots of different kaleidoscope patterns, but the simplest one is where you just have a triangle tiled over and over again, such that for any two adjacent triangles, each is the mirror image of the other across the border between them. There are several examples of this on the linked Wikipedia page. Turning the dial(s) on the side of the kaleidoscope will change the contents of the triangle (and thus the contents of your entire field of vision), but not the pattern itself - it will always be repeating triangles in the same locations.

Drawing a basic kaleidoscope

My initial idea for this was to take a large, continuous image and basically cut a single triangle out of it and then draw it over and over again, flipping it as appropriate. (To my mild surprise, this did not require any rethinking later.) The player’s three input controls would just control the x- and y-offset of that source triangle in the larger image, and the rotation of that source triangle around its own “center” (here defined as the intersection of the three lines that bisect the triangle’s corner angles, which I guess is properly called the “Incenter", a word that I learned just in time to write this sentence).

Getting the source triangle

Now, to “cut out” this triangle, what I actually did was take a separate canvas element, paint it with transparency, paint a solid (white, although it doesn’t matter) triangle on it, and then paint the giant image over the whole thing, using the globalCompositeOperation value of “source-in”. This meant I had to do a series of transformations on that triangle-placeholder canvas so that when I drew the giant source image, the correct portion of it lined up with the triangle.

Graphics translations are weird and often counter-intuitive - or at least, as a person who has rarely done them, that’s how they seem to me. If you already do a lot of graphics work, maybe this is super obvious:

DrawTriangleFromOffsets(glassCtx: CanvasRenderingContext2D, index: number) {
    glassCtx.resetTransform();
    glassCtx.translate(0.5, 0.5);

    if (index == 0) {
        this.DrawSourceTriangle(glassCtx);
    }

    glassCtx.globalCompositeOperation = 'source-in';
    let glassImage = this.backgrounds[index];

    // Need to apply rotation here, but it should be at the "center" of the triangle,
    // which is actually (maxOffset, maxOffset)
    glassCtx.translate(this.maxOffset, this.maxOffset);
    glassCtx.rotate(Math.PI * 2 * this.state.rotation / 100);
    glassCtx.translate(-1 * this.maxOffset, -1 * this.maxOffset);
    glassCtx.drawImage(glassImage, -1 * this.state.xOffset, -1 * this.state.yOffset);
}

To clarify some variables and stuff:

  • glassCtx is the one that I’m drawing just the triangle on
  • I’ll explain the array of backgrounds later, but for now just think of it as the giant source image
  • maxOffset is the distance from the triangle center to its corners, which means that if the canvas is a square that shares a center with the triangle, it must be 2 * maxOffset in length
  • I happened to be feeding in values for rotation that were integers between 0 and 400 for no particular reason
  • This is all part of a React component, which is why it’s grabbing stuff from “this.state”

So, to pick out the triangle at point (xOffset, yOffset) and rotate it about its own center, first you need to translate over to the point where that center will be (which is (maxOffset, maxOffset)), then you need to do the rotation, then you need to translate back to the origin (although now it’s really not the origin thanks to the rotation), then you need to translate by the opposite of the offset you’re looking for in the source image (which is (-xOffset, -yOffset)), then you can paint the source image onto the destination. Note that all of these translations took place on the destination canvas, not the source image.

As I’m writing this, I’m realizing I’m not entirely sure if I could have instead applied a translation to the source instead. My intuition is that I could not, because the translations are called on the CanvasRenderingContext2D rather than on the HTMLCanvasElement from which that context is derived, but the drawImage(...) call is made using the HTMLCanvasElement as input, so I would think the translation would not be respected, but I never thought to check, and at least a quick scan of the documentation has not immediately answered this for me.

On the flip side, my general understanding is that lots of graphics stuff involves translating one thing or another and that it’s really common to e.g. move the entire world rather than the camera so it’s a good idea to work on building up intuitions around these operations in either direction.

Anyway, this yielded the desired source triangle, so the next step was to draw it over and over again to the destination (i.e., the kaleidoscope canvas).

Drawing the kaleidoscope from the triangle

As I mentioned above, the canonical simple kaleidoscope I chose was one where each triangle is the mirror image of the one on any of its sides. Note that this is not the same as just rotating the triangle. In fact, it took me a while to figure out what series of operations I needed to do to produce the correct triangles (side node: I initially wrote “right triangles” and then was immediately bothered by it).

One thing I quickly realized by drawing it out on paper was that you really only need 6 different versions of the triangles, arranged in a hexagon, and then tiled over and over again. This ended up being somewhat helpful for organizing the code later on, but it didn’t answer my question as to which flip/rotate sequence I needed. Eventually I figured this out by tearing little paper triangles out, labelling their corners, and playing with them for about 30 seconds. This made me feel silly about the, I dunno, 20 minutes I wasted before that. It also made me feel silly because I’ve learned lots of algorithms in the past by scribbling them down or tearing up bits of paper to represent them, but for some reason it didn’t occur to me to do this in a graphics context, arguably the most intuitive place possible for it.

Anyway, once I did that I realized that adjacent triangles were rotated by 60 degrees (or pi/3) and then flipped horizontally. I then briefly confused myself by not realizing that you can’t just chain these operations together - once you flip something horizontally, rotating it by pi/3 is now doing the opposite thing it did before.

I don’t have the intermediate version of this code handy, but this is from a later version that draws all 150 triangles in the final image:

DrawFromTriangle(triangleCanvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, index: number) {
    let hexIndex = index % 6;
    let hexRow = Math.floor(index / 6) % 5;
    let hexColumn = Math.floor(index / 30);

    // The 2-index column/row is the center hex.
    ctx.resetTransform();

    // For drawing within a given hex, we change its center to be (0, 0)
    let xOffset = 350 + (hexColumn-2)*this.side*1.5;
    let yOffset = 350 + (hexRow - 2) * this.height * 2 + (((hexColumn % 2) == 1) ? this.height : 0.0);
    ctx.translate(xOffset, yOffset);
    
    // Next, we align ourselves for one of the six triangles in the hex
    ctx.rotate(hexIndex * Math.PI / 3);
    if (hexIndex % 2 == 0) {
        ctx.transform(-1, 0, 0, 1, 0, 0);
    }

    // Finally, we need to align the triangle.
    // The 'neutral' position is the top one pointing downward.
    // The above logic would draw the box with the triangle starting
    // at what should be the bottom point of the triangle.
    // To fix this we need to move back vertically the box length,
    // and horizontally by half of it.
    ctx.drawImage(triangleCanvas, -1 * this.maxOffset, -2 * this.maxOffset);
}

Clarifications:

  • I don’t know the proper name for this, but this uses something similar to, like, bitwise flags to specify the triangles
    • The first 30 items are in the first column, the next 30 items are in the second column, and so forth
    • Within each column, the first 6 items are in the top hex, the next 6 items are in the next hex, and so on
    • Within each hex, the items are numbered 0-5, with 0 being the top-middle triangle and the rest numbered clockwise

Apparently once I got the operations in this order I no longer needed to worry about the rotation direction vs. the flipping - as you can see I reset the transform before each triangle, so I built the whole thing from scratch rather than doing it relative to the previous triangle like I did in earlier versions.

This is basically enough code to cover drawing an ordinary kaleidoscope. I tried generating a fractal using Apophysis (I feel like I’m somehow dating myself here that that was how I thought to general a fractal?) and it actually looked pretty cool right off the bat.

At this point, all I had to worry about was the magic part of the kaleidoscope. Although that was still a bit complicated.

The magic part

So, as I said, this was supposed to be used as part of a sort of fantasy-themed escape room, so the kaleidoscope contained clues that were related to the other artifacts. Specifically, I made four “solutions” where, if you set the dials to the right value, it would show you a specific image. These images were not just the same triangle mirrored over and over again - they were whole pictures. But, I wanted them to operate in a manner that was continous with the rest of the kaleidoscope, e.g., if you changed the rotation dial, each triangle would be rotated by that amount, or if you scrolled off the solution by a few pixels, each triangle would be off by that amount. This half-obeys and half-breaks the presumed laws of physics that I was obeying when I drew the rest of the kaleidoscope, which is what I found fun about it.

I ended up using what I suspect is an unnecessarily inefficient solution to this, but which basically worked fine in terms of client performance. Specifically, I drew 150 identical copies of the background image in separate canvas elements (the “backgrounds” array that I said I would get around to explaining). Then, on each of those canvas elements, I drew a different triangle cut out from the “solution” image, translating and rotating it to the same location/orientation on each image. Thus, when I cut out the triangles and assembled them according to the logic above, they would yield the original image.

AddSolution(yDial: number, xDial: number, rotDial: number, bgGlassCanvas: HTMLCanvasElement, bgGlassCtx: CanvasRenderingContext2D, image: CanvasImageSource) {
    for (let i = 0; i < 150; i++) {
        this.DrawSourceTriangle(bgGlassCtx, true);

        let hexIndex = i % 6;
        let hexRow = Math.floor(i / 6) % 5;
        let hexColumn = Math.floor(i / 30);

        // These offsets are based purely on the hex indices
        let offsetX = 350 + (hexColumn-2)*this.side*1.5;
        let offsetY = 350 + (hexRow - 2) * this.height * 2 + (((hexColumn % 2) == 1) ? this.height : 0.0);

        // To account for this, first we need to rotate this triangle relative to the center of the hex,
        // which in the draw logic means rotating it relative to the bottom point
        bgGlassCtx.translate(this.maxOffset, this.maxOffset * 2);

        let rotation = hexIndex * Math.PI / 3;
        let flip: boolean = i % 2 == 0;

        bgGlassCtx.rotate(rotation * (flip ? 1 : -1));
        if (flip) {
            bgGlassCtx.transform(-1, 0, 0, 1, 0, 0);
        }

        bgGlassCtx.translate(-1 * this.maxOffset, -2 * this.maxOffset);

        
        bgGlassCtx.globalCompositeOperation = 'source-in';

        bgGlassCtx.translate(-1 * offsetX, -1 * offsetY);
        
        bgGlassCtx.drawImage(image, this.maxOffset, 2 * this.maxOffset);

        bgGlassCtx.resetTransform();
        
        // Also, odd-number columns are shifted up by one triangle height
        let bgCtx = this.backgrounds[i].getContext('2d')!;

        // Also fade out the background around this triangle for a more gradual input
        // TODO: Consider adding overlapping/faded version of solution

        let xVal = this.NumberToX(xDial);
        let yVal = this.NumberToY(yDial);
        let rVal = this.NumberToR(rotDial);

        let rAngle = Math.PI * 2 * rVal / 100

        bgCtx.translate(xVal + this.maxOffset, yVal + this.maxOffset);
        bgCtx.rotate(-1 * rAngle);
        bgCtx.translate(-1 * this.maxOffset, -1 * this.maxOffset);

        bgCtx.drawImage(bgGlassCanvas, 0, 0);

        bgCtx.resetTransform();
    }
}

You may notice this basically contains all of the same translation/rotation operations I used in the previous bits of code, but the orders of them are different. To briefly review:

Extracting the tiled triangle from the base background:

  1. Translate the destination context to the center of the triangle
  2. Rotate the destination context
  3. Do the reverse of the initial translation to get “back to the origin” (but not exactly, as noted above)
  4. Draw the source image at (-x, -y) where (x, y) is the location in the source image we want to extract

Drawing the tiled triangle onto the kaleidoscope: For each triangle:

  1. Translate to the center of where we want to place it
  2. Apply any rotation
  3. For every other triangle, flip horizontally
  4. Draw the triangle at (-maxOffset, -maxOffset), i.e. so its center is at (0, 0)

I think that’s everything, but if by some miracle you made it this far and spot something I missed, let me know.

Live version

I’m sure at this point you’d like to know what all this actually looks like, so I have uploaded a static version of the kaleidoscope. This is basically the experience one of my four players had during this session, except that there was also some server-side wiring and the solution images only became visible once certain stuff happened with the other players.

For reference, you can turn the three dials by holding A/Z, S/X, and D/C, and the solutions are at:

(3, 3, 3)
(13, 4, 4)
(3, 9, 4)
(27, 9, 9)

The first three should all be pretty self-evident, so if they don’t seem like clear things, fiddle with the dials a bit. (The last one is just a pair of symbols overlaid on a spiderweb, which meant a lot more to the players at the time.)

What I Learned From This

  • How to spell “kaleidoscope” - there’s a lot of vowels in there and I am not ashamed to admit I did not initially put them in the right order every time
  • Working out graphics operations with physical objects is a lot easier than trying to think about them
  • A lot about how to translate/rotate/transform graphics contexts to draw stuff
    • Including when you have to transform the destination context rather than the source

Remaining Issues

The draw-150-copies-of-the-same-giant-image logic seems like it’s probably wasteful. As I said it didn’t seem to impact client performance too much but it was a bit touchy at times, so I would like to see this improved if I ever work on it further.

Also, I found that a lot of times when drawing to the canvas, it seemed like I would wind up with borders around the triangles, but not always, and if moved the inputs and then moved them back, it would end up looking a little different. Clearly there’s something I don’t totally understand about this, and given enough time I would like to correct this so it doesn’t just seem to have random little artifacts.


Timothy Bond

2759 Words

2020-12-03 09:30 -0800