3D Graphics on the Web as a Teaching Tool
Posted
If you know me personally, you know that I love to rail on web technologies, especially JavaScript/ECMAScript and its long list of WTFs. So why did I recently tweet about a hybrid article/web app I wrote? Let me explain. And spoiler alert: I'm not planning to switch to web dev any time soon.
So first off, what's the app all about? It is an article with interactive, 3D diagrams designed to help teach students who have had little to no contact with graphics programming about homogeneous coordinates and rational splines. If you haven't encountered either yet, please check it out first and tell me about your experience before reading on!
I took on this project as my individual teaching project in Module 3 of the Higher Education Teaching Certificate offered by TU Darmstadt. If you work at TU Darmstadt and have any interest in teaching at all (or are supervising master's and bachelor's theses), I highly recommend checking out the courses they offer. By the way, if you are a PhD student there, many of the same courses are offered by Ingenium as well. If you are at a different university, check out what they offer. Many have similar programs these days — at least in Germany.
The idea underlying the project is to help students gain an intuitive — not just theoretical — understanding of homogeneous coordinates and rational splines by supplementing the theoretical description with interactive, 3D diagrams. While others have created similar articles with interactive 2D diagrams, the extra dimension allowed me to take two-dimensional homogeneous coordinates, which are represented as three-dimensional vectors, and visualize the projection process as well. And while the final display screen itself is flat, humans are pretty good at interpreting flat projections of 3D worlds, especially if the viewpoint can be changed. I guess you could also try doing some WebXR shenanigans for true stereoscopic 3D visualization, but that would have been way beyond the scope of the project. Furthermore, that would have gone against a secondary goal I had in implementing the project: as low a barrier of entry as possible.
Not everyone can afford their own computer. Many only have a smartphone. Some may not even have that and only use lab and library computers on which they can't install anything. While luckily increasingly rare in this country, these students exist even today, even in computer science and related degree programs, and we shouldn't exclude them — or anyone except those who would exclude others. Since an installable app was out of the question, this really left me with only one option: a Web application using WebGL (not WebGL 2 or WebGPU, since that would run counter the goal of a low barrier of entry). As access to technology isn't the only factor that may limit accessibility, I tried my best to make the page as accessible as possible by:
- Using semantic HTML5
- Choosing colors that fulfill contrast guidelines and respect the user's dark or light theme preference
- Using KaTeX for formula rendering, as it includes MathML annotations, not only an image or SVG like some other systems
- Making the points not only editable in 3D, but as textual input fields as well
- Also rendering the projected positions as a table in the alternate fallback content of the canvas elements
Is this sufficient? I honestly don't know, as I'm no accessibility expert, nor do I have any experience using screen readers or other assistive technologies, but I tried my best — should you find an issue and ideally have a suggestion how to fix it, please file an issue.
Now, as I mentioned up front: I am not nor do I plan to become a “Web developer,” so the development of the app was a bit of an odyssey of failed attempts, underestimated efforts, and probably a good dose of unusual (questionable?) approaches — the main ones probably being writing raw HTML5 and CSS and not using any “frameworks.” So let's have a look at the failed attempts before we address the solution I finally went with.
Failed Attempt №1: Elm
The first thing I tried was Elm. Yes, believe it or not, the first thing I went for was Elm, not Rust which I know nor JavaScript which almost everyone uses for these things. So why Elm? I like learning new things! New programming languages can often teach you new approaches to solving programming problems. One such approach — functionally reactive programming (FRP) — is a pure functional approach to UI programming and provides the foundation for Elm. Or so I thought.
It turns out they dropped FRP as a core part of Elm in 2016. And despite starting the project in 2020, most of the Elm tutorials popping up in my filter bubble were still using older, FRP-based Elm. This didn't stop me though, as Elm is still a pure, functional language with a syntax very close to Haskell, and I haven't done any functional programming in forever. The last time I wrote anything remotely close to pure functional code was a few lines of Racket (a LISP-derivative) in my first year of university 16–17 years ago.
Since Elm has (experimental) WebGL support, I quickly had the basics of drawing something up and running. I ran into some DPI-related mouse pointer handling issues that I had to work around by creating my own JavaScript event handler that performed the necessary computations before forwarding the data via an Elm port. But aside from these minor technical issues, things seemed to go well, so I kept trudging along, adding element after element to my renderer.
But after a while, I noticed things were getting slower and slower, to the point of a significant drop in frame rate (think less than 30 FPS) on my desktop machine, which has a fairly beefy GPU, as I'm also a gamer. Not good if I want to keep it interactive and keep the barrier of entry low. While the Elm WebGL library applies a few tricks to avoid re-uploading meshes every frame, it failed in many cases. Why are these tricks even needed? Since Elm is a pure functional language, you don't modify any state, but create a new state — including everything that should be rendered — every time. While Elm's lazy evaluation helps to avoid a lot of computations, the structure of the code and even the order of parameters has a significant effect on what is or isn't memoized. And what Elm WebGL can memoize is even more limited. I tried restructuring it a bit and was able to improve the frame rate slightly. However, the necessary refactoring made the code more difficult to understand not less. And even then, the frame rate was far from great, so I gave up on using Elm.
That certainly doesn't mean it was all bad, though. The Elm programming model works really well for more traditional applications. And like Rust avoids memory safety bugs at compile time, Elm avoids runtime exceptions and catches almost all errors that can happen with regular JavaScript at compile time. This even extended to the Elm WebGL library type checking shader vertex attributes and uniforms at compile time! 🤯 Also like Rust, the Elm project puts a lot of effort into producing helpful and friendly error messages. At this point, a big shout-out and thank you to Esteban Küber and everyone else working on improving Rust's error messages! ❤️
Failed Attempt №2: Rust
For my second attempt, I decided to go for something more familiar: Rust. Between its strong cross-compilation support, including three different WebAssembly (wasm32
) targets with Tier 2 support, and the great work of the Rust and WebAssembly domain working group, I thought it should be interesting to try using Rust for graphics on the Web.
Despite using Rust, I wasn't able to avoid npm
, webpack
and other parts of Web programming that I hate strongly dislike with this approach, but based off the web-sys
WebGL Example I was quickly able to begin drawing to the screen. However, I then realized how much I would have to do and bind myself that Elm WebGL did for me, such as getting all the uniform and vertex buffer types right. And event handling (for mouse events, for example) felt really off with the fact that you have to box and forget closures… All solvable issues that I have solved several times for desktop OpenGL already, but this was a side project happening late in the evening after 8+ hours of work or over the weekend, so I really couldn't afford the amount of work it would entail.
I considered using an engine with WebGL support, but most engines aren't exactly designed for drawing 3D diagrams that are mostly programmatically generated and include things like prescribed screen-space width lines. Furthermore, I wanted to have multiple diagram instances with different input data and most engines focus on a single window, and from what I could tell, multiple instances of the same WASM module aren't supported. Also, even in its minimal form, the generated WASM files were surprisingly large.
Since I was already running short on time, I had to cut my losses, stop faffing about, and use something that just works. However, I really want to explore Rust for interactive graphics and the Web more at some point in the future (looking at you, wgpu-rs
👀)! For one of my non-interactive (and non-Web) graphics experiments in Rust, check out l0calh05t/raytracing-weekend-rs
.
The Solution: TypeScript with Three.js
The solution I finally landed on was using TypeScript with Three.js. While I don't usually do Web development, some of my colleagues use Three.js with TypeScript for 3D Web apps, so I knew it works well. Three.js abstracts away a lot of the details, but allows programmatic generation of geometry and supports wide lines. Plus it supports both WebGL and WebGL 2.
Compared to raw JavaScript, TypeScript has static typing (which you can locally escape if necessary, which I had to do a few times where type declarations were out of date or incomplete), which avoids a lot of JavaScript's insanity — thereby allowing you to keep your sanity. Furthermore, the transpiler can take advanced/modern ES6+ constructs and compile them down to older, more widely supported ES standards. So I found it a lot more comfortable to work with than raw JavaScript.
Most of the pain I had setting up the project, was that webpack v5
had fairly recently stabilized, and like the Elm situation before, most information on the Web still referred to webpack v4
. But after that, the rest went pretty smoothly. I even got a nice orbit camera controller with full pinch-zoom support, inertia and everything else essentially for free. Interaction with scene objects was a bit of a hassle though, since none of the libraries I found worked out-of-the-box, either due to mismatches with the current (at the time) Three.js version or the lack of TypeScript type information. In the end, I had to use a local vendored copy of markuslerner/THREE.Interactive
. But aside from these minor issues, I can't complain!
That's all for now, and we'll return to our regularly scheduled Rust programming next time. If you have been following along, you know the drill: feel free to follow me and send me a DM on Mastodon if you have any questions or comments! I would be very happy to receive feedback regarding the app (especially should you decide to use it in a course!). While I have used it in a course and prepared a short feedback questionnaire, none of the students submitted the questionnaire, so I really don't know if it was useful to anyone — or if it was perused at all. At least the colleagues to whom I showed it liked the concept and the design.