Graphics / Non-Photorealistic Rendering
Two approaches toward watercolor rendering: a full multi-pass pipeline (Approach 1) and an anisotropic Kuwahara-style filter grounded in G-buffer geometry (Approach 2).
This project was like the ship of Theseus. At what point does the project become a new project? Before we dive in and unravel that statement lets go back to the beginning, the research. I decided to do this project because I thoroughly enjoyed making our shaders during the in class activity. After that class, I talked to Professor Gossett about potential final project ideas, and he came up with the idea of non-photorealistic rendering techniques. I thought that being able to do something like watercolor could be fun.
So the research began. For my first attempt, I found two sources that would help guide me through the project for the first three quarters, namely Hong and Turn. I decided I didn't want to just follow a tutorial, so I seldom used Turn's tutorial and focused on the project Demo by Hong and team. Turn, however, gave me the general understanding of how watercolor works, modulating the edges of the paper, switching from RGB to CMYK to be able to do proper color mixing(Via subtraction). Hong gave me a visual guide to work with after. When diving into Hong's work, I quickly ran into an issue. They were using three.js to run their program, which simplifies a lot of the program for them. It took me two days to just understand how their code worked, once I understood that, I took off making a skeleton of a pipeline that I could plug new shaders into. Kyprianidis was the most interesting read of all of my work. It built on the Kuwahra method I used in my later iterations. It was mentioned in Heckel's blog and it uses what I learned in Linear algebra(Eigen Vectors and Values), Karpushin also helped greatly in my understanding of the Eigen Vector's and what they information they conveyed. I skimmed most of my other sources for various theories and used ChatGPT to scan the papers and get a high level view of them. In retrospect I think this aspect of my research hurt my understanding the most. I should have dug in deeper instead of attempting to rely on ChatGPT.
We will call all future references to this project as Approach 1 and Approach 2, with Full watercolor rendering pipeline, and the Kuwahara / anisotropic filter respectively. With Approach 1 I felt overwhelmed and started by sketching out a pipeline where I could go from pass to pass building up to the overall combination of watercolor. This worked out alright but in turn left me in a spot of not having a complete understanding of the watercolor pipeline. With Approach 2, I started by learning from what I disliked with Approach 1. Namely that I did not modularize my render passes enough. I looked to three.js for inspiration on how to accomplish this. After that I took to the plan of implementing each shader pass.
I built many failures. I had done approach 1 with 5-6 iterations. Each iteration involved somewhere in the realm of 16 different shaders with 13 complete passes. After each iteration I would show it off to people and they would think it looked terrible and not at all like watercolor. At first I thought I just needed to tune the constants more, but over time I came to realize that my math must be wrong. With Approach 2 I only had the time for one iteration and it looks alright, my Girlfriend says it looks more like spray paint then watercolor, but I like how it turned out, albeit not what I initially intended.
In approach 1 my Shadow and Midtone pass worked amazingly, every other pass did what I wanted for the most part, but when combining them all together I could never get them to look right. I also think that my first pass was wrong, I was ignoring specular highlights in my base wash when I should have been either painting them pure white or discarding those fragments. I also think part of the issue is that I was doing math as if it was just one object that I was painting, not a whole scene, I think this led to some of my issues with texture layering. Approach 2 works for the most part but the artifacts that I get from Kuwahara / Anisotropic do not look as much like brush strokes as I would have wanted or what I expected.
Looking at what I learned from this experience is three things: Research earlier and deeper, modularity and easy code is important, and finally, ask for help sooner. Looking at approach 1, every time I restarted, I would barely touch my GraphicPipeline.ts, and it was pretty terrible to work with. Adding a new shader involved retyping about 30 lines of code each time. So I decided to completely overhaul my pipeline. Over the iterations, I had broken things out a little to save me time, but I needed to do more. There was also a performance aspect, as I was achieving uniform locations on every frame. With 13 passes, I noticed the frames struggling to keep up with it. After the second iteration, I had made a ShaderProgram.ts that held the Buffer, ShaderProgram, and Texture so I could easily make a new pass by making a new one of those, but I still needed to duplicate things like uploading files. For approach 2, I ended up breaking it out into 6 different files so that way adding a new pass would be 5 lines of code, not 30-60. Doing this modular system of which the idea I was borrowing from Three.js. I was fixing all of the tedious work that had been bothering me when restarting the project. Back to Approach 1, I think there is a way to make that iteration of my program work, but I was running out of time. Looking back I should have asked for help a lot sooner than I did.
For approach 2 I found new resources to help me. A Kuwahara filter was the approach that I took, so I first adapted some code from Heckel and it gave some of the desired results that I wanted, but Heckel was merely doing a post process effect on any image, not taking into account any of the underlying geometry. So I took to rewriting their shader to try to implement the geometry as best I could in the time I had left. Instead of sampling the previous pass, I would sample my GBuffer instead, deriving everything from that and then combining them with weights at the end. And the result turned out alright, but certainly not watercolor. I was defeated again.
Overall, through this process I learned a lot of things that I will take with me in my future career and next semester of school. The first and most important is that doing more research early on is better. I did not deep dive as well as I ought to at first, and it bit me the back on the latter half of the project. The second is developing things modularly from the beginning. This point is more interesting to me since I already try to be as modular as possible when writing code, but I still struggle. I often found myself saying, its okay that I am repeating myself, this project is almost over. In turn, this led to a mess of code that was just unfun to maneuver inside of. Overall, I would say I enjoyed this project, not because of my end result, but because of the skeleton I have for making future shaders and how "easy" a system I developed to do that with. I hope to combine both approach 1 and approach 2 at some point before I graduate. I want to see if I start with approach 2 and then feed those into my approach one passes if I can get the desired result.