Episode #2 Shader Doodle!
In Episode #1, we never really finished our project to package it up for deployment somewhere. Rollup
is a great choice for us, especially because we're already using Rollup in @web/dev-server
for node module
resolution and our various plugins.
So, in this last step, we'll install Rollup and create a build task in our package.json
.
We'll start by npm install
-ing a few packages. First, Rollup, and then some more plugins like @rollup/plugin-node-resolve
to do the same module resolving work as our server, @web/rollup-plugin-html
to manage our HTML page and linked script, as well as
rollup-plugin-sourcemaps
and rollup-plugin-clean
to include sourcemaps and clean our build folder before builds.
Once installed, we can create a Rollup config file and set it up in much the same way, using largely the same plugins
and options as our web-dev-server.config.js
.
Lastly is adding our build task and running it. We can now run our bundled file with associate HTML file right in a browser on any server, ready to be put online!
Let's finally render a GIF from Shader Doodle. There are some technical challenges here, but first, lets start with the UI.
We're going to add another reactive property called recording
. Even though it will be used internally
in practice and can be decorated as @state
, we're going to fall back to @property
. The reason is that
we'd like to hide our recording settings when recording, but also show a progress bar when we do.
So, first, we'll import the Spectrum Web Components sp-progress-bar
and add it to our component markup.
A @state
decorated reactive property called frameRecording
will also be added to our class. This property
will indicate which frame we are currently recording, and we'll use frameRecording
vs framesToRecord
to calculate
progress.
Because recording happens for a limited time, and so will this state of our UI, we can set this new recording
property
to true or false manually to show either state. We also set it up to reflect so we can use the attribute in
our dev tools to flip this property on or off and test the transition of the UI back and forth before we actually wire
up the record function.
The outcome here is that we'll be adding some CSS rules to show or hide elements depending on this recording
property value.
And now the fun part, getting our GIF recorder to work. This is going to be a bit tricky, but we can show off some
more @web/dev-server
plugins. Which, again, are actually Rollup plugins - so we have a huge library to choose from.
As we install and import GIF.js
, we quickly see that it won't work as is. First off, it's a Coffeescript based library, which even
the bundled version doesn't quite work as an import. So we'll reach into it's source folder, skip the Coffeescript and go straight
to the JS files that originated as a Flash/Actionscript 3 library and was converted to JS over time.
Unfortunately even THESE JS files use CommonJS which don't work as an ES Import. So we'll fix this problem by installing
and using a Rollup CommonJS plugin. This plugin will transform the require
calls to be compatible with ES modules and allow us
to use the library.
After this gets added to @web/dev-server
, we find that there is an additional problem. Our browser starts erroring out because
some variables aren't defined before use. This problem is the result of this older library not conforming to strict-mode JS.
ES module imports actually do enforce strict mode, so there's no way around this problem.....or is there?
We can use the Rollup Prepend plugin to insert Javascript code into this one problematic file as its served. We'll simply insert some variable declarations at the beginning.
And with that, we can import our GIF encoder and set up the function and timer to capture frames, saving them as a GIF at the end of the recording period.
So here's the thing with Shader Doodle. It's pretty awesome, but doesn't seem designed to keep switching shaders on the same component instance. It seems designed to be setup once in code and then run. Our SpaceDoodle app probably falls outside the normal usecase!
That's OK, we can work with this! Lit is designed to make minimal changes and not tear things down. This means that when an attribute or your component's slot changes, that's all that's going to change. Your component as a DOM element will not get torn down and re-rendered. Your constructor won't get called again and it won't get removed and re-added to the DOM.
But in this case, this is actually the behavior we want! At least for this tiny section of our UI. We want to remove Shader Doodle from the DOM so that it gets torn down, and then add it back with a new shader such that it gets a fresh start. To do this, we'll fall back to the very behavior we use Lit to escape from: appending directly to the DOM.
So, we're going to manually add Shader Doodle to <div id="shader-doodle-container"></div>
. To do this, in our shaders.ts
file we'll be adding a createShaderHTML
function which constructs a tag containing the correct vertex and fragment
script tags, as well as the appropriate texture custom element for the shader. But, we're going admittedly low-tech here.
We're creating this tag through string concatenation and then with the shaderUpdate
method, we set the innerHTML
of the Shader Doodle container to this string. But just prior tohat we set the innerHTML
to an empty string.
This effectively removes the element from the DOM, and forces it to recreate itself in its entirety with the new
HTML string.
In this same shaderUpdate
method, we'll update the code editors as well by using the setCode
method of these lit-code
components. We'll also want the code editor components to update the shader. To do this we'll want to listen for the lit-code
@update
event. We'll create a brand new custom shader object with the new code by cloning the current shader object.
Of course to update all of these elements, we need references to them. And for this, we can use the query
decorator.
Lastly we'll use Lit's firstUpdated
method to call this new shaderUpdate
method against the first shader
in the shader list.
The code editors and Shader Doodle will be a bit more difficult to hook up, but we can at least get the easy stuff out of the way.
Starting with the color picker, we'll add a @property
decorator. This is a reactive property, and is something
we explored in Episode #1. This time however, we can use a simple decorator instead of the wordy syntax in a
pure JS project.
By hooking up the input event to the color picker, setting the current color
attribute on each to this new textColor
property, the color slider and area are now affected by user input. To finish up, we can add this CSS property as an
inline style on the editable text element.
We can do similar with the actual editable text content. For our purposes, however, a reactive property isn't needed
here. The only place this editable text will appear is here on this div
. And then in a later step, we'll use this
text to render a GIF of our shader + text overlay. We just need to update this text
property when our field updates.
So after creating the property (non-reactive), we'll add an input event on this contenteditable
div
to simply
keep the property updated as the user edits it.
Also easy, we can wire up our sliders that control how many frames we'd like to record for our GIF and time between snapshots.
For this, however, we won't use the @property
decorator for these properties. Instead, lets use the more appropriate @state
decorator. This is new in Lit 2 and meant for internal properties that we don't care to expose outside of the component.
Incidentally, it would have been more correct to use them for the colors and text. These reactive properties will now update
the label below the sliders to indicate what the timing of the recording will be.
To finish up this left side, let's just fire off an alert as a placeholder for the function to record our GIF. So we'll
create this placeholder function that uses the @click
listener on the "Record and Save GIF" button.
Lastly, we'll get the pickers/comboboxes working on the right side. These control the "shader" and optional
texture used for the shader. The menu items for the shader picker will be driven from the list of shaders found in shaders.ts
while the textures will just be an array of images in our assets folder with the addition of a web cam and the option
to not have a texture at all.
Given that Shader Doodle is a bit abnormal of a component (I'll discuss why in the next step), we won't actually take action on loading the shader quite yet.
But wait! Even after we've wired this up, the picker menu is having some issues displaying when clicking to open it up.
We're back to the issue in Episode #1, where we get a process not defined
error. Again, this is due to an overlay
management library trying to query if we're using Node.js or in a browser. We fixed this with a hack before, but now lets
fix it properly.
In our web-dev-server.config.js
, we'll remove true
from the nodeResolve
object. This object is much like the lit-css
plugin we're using, but nodeResolve
is so important and central to how @web/dev-server
works, it's a top level
configuration option. Typically, you'll just want it turned on, so true
is what you'd set this to. However, to give
it more of a configuration, we can set this to an object. We'll do that, and use the exportConditions
property to set this
in production mode. Doing this injects the process
object and allows the internal 3rd party library to know
that what we're doing is inside the browser.
Now it's time to pop in our remaining components. Of COURSE we need Shader Doodle but, we'll need a code
editor as well. I found something called lit-code
which is a PrismJS + Lit based code editor.
We'll add those packages to our package.json. Note that for Shader Doodle we're pulling the experimental alpha.
We should also import PrismJS so it's default languages like JS can have some nice syntax coloring in lit-code
.
It might make more sense to set the language to HLSL (shader language), but it's a bit of a hassle to get
working, and I've found that it looks virtually identical to JS in practice (at least as far as the color styling goes).
Starting slowly by experimenting and adding shaders manually to our Shader Doodle HTML markup, we'll get a bit more
organized and use the separate shaders.ts
file to hold and export a set of sample shaders. We'll do the same with
the lit-code
component. As a middle step, however, we'll store the entire shader script tag in a variable and use
Lit's unsafeHTML
to render it, just to show this particular escape hatch exists when we need it.
Lastly, we can add some dark style CSS vars to our 'lit-code' element to match the dark mode we already have for the overall web app with Spectrum Web Component.
It's time to add our UI. We'll be adding (mostly) non-functional UI as a first step of building our application. We will NOT be paying attention to organization, so we'll be overloading our one single component with all of our markup. For a real application this would be less than ideal - it's better to split things up more granularly as smaller and less complicated components, but thats not what we'll be focusing on today, so I'm allowing this project to get a bit messy.
We start by adding Spectrum Web Components to our package.json. For Episode 2, I'm using dark mode in Spectrum just because in Episode 1 we used light mode.
The application will be divided into two sections on the left and right. On the left,
there will be a canvas area where editable text overlays the shader-doodle
component.
Below that there will be some controls for setting the text color and recording a GIF
of the canvas.
On the right, we have some dropdowns to allow us to load different sample shaders, and different textures (if applicable).
Also, one nuance of shader-doodle
is additional configuration to run "ShaderToy" shaders, so there is a switch
to turn that off and on.
Below that is an accordion menu that will contain text editing capabilities for the vertex and fragment shaders for the shader set. And lastly, a simple button to reload/refresh the shader after a user has made edits.
But of course, none of this is wired up!
We start our "Space Doodle" app with a bit of front-end tooling setup. For this project, we'll be using Typescript and delve into using some web-dev server plugins.
To begin, we start with installing 3 packages
- Lit - we used this in Episode 1 and we'll be using it again to help with Web Component dev
- @web-dev/server - Also used in Episode 1 and we'll be using it again to serve our dev environment
- Typescript - Adds types to our JS variables and functions, but also is one way to use decorators in Lit
Next, we'll set up some tasks in our package.json. New to us will be the Typescript compilation task, and
the task to transpile and watch our TS files. The serve task that launches our page for development has
been covered in Episode 1 and adds TS transpilation to the mix. TS and serving are done with an ampersand
(&) so they both execute in parallel. TS files will be watched and when changes happen will be transpiled to JS.
And these JS files that were transpiled will force the page to be reloaded. We'll also need a simple tsconfig.json
file to kick us off with some light and relaxed settings for Typescript.
Now we're ready to create our app. We'll be creating our index.html file which, like Episode 1 will be styled and sized to the full page and doesn't scroll.
We'll also add the doodle-app
component/tag/element to our page body, and include a script link to our
"Space Doodle" app entry point found at src/doodle.js
In our application entrypoint, doodle.ts (which is Typescript), we'll create a mostly empty class. This class
will be our doodle-app
web component which uses Lit by extending it. Inside this class will be the
Lit render
call which renders nothing yet (by way of an empty html tagged template).
To define the Web Component, we'll be using our first "decorator".
Next up, we'll demonstrate adding style to our component. Unlike Episode 1, we won't be using CSS in JS,
or rather we won't LOOK like we are. We'll start by creating a doodle.css
file and adding a simple :host
rule to make our page red.
We'll import this CSS in the same way, however it won't quite work yet. We'll need to create a web-dev-server.config.js
file to start editing our web-dev server config. Here we'll allow the CSS MIME type to be treated as JS, as well
as using the rollup-plugin-lit-css
Rollup plugin to wrap our CSS inside of Lit-ready JS, so we can import
it into our component.
Lastly, to make Typescript happy, we need to create a global rule to give any CSS we import a proper type definition.
With all that, we have a full page application with simply a red background.