Signed distance field
I did a generative art piece based on signed distance fields using javascript but thought that naturally it'd be doable in Nodebox as well. As a POC for myself I did simple SDF circle drawing network. Very straightforward actually. More complex SDF:s would naturally require more vector calculation helper networks.
- Screenshot_2024-05-08_at_23.48.29.png 929 KB
- sdftest.ndbx.zip 2.54 KB
Keyboard shortcuts
Generic
? | Show this help |
---|---|
ESC | Blurs the current field |
Comment Form
r | Focus the comment reply box |
---|---|
^ + ↩ | Submit the comment |
You can use Command ⌘
instead of Control ^
on Mac
1 Posted by Jussi Jokinen on 09 May, 2024 08:52 AM
Here's another a bit more complex version with multiple SDFs in the same grid. There's a rectangle SDF alongside with a circle. SDF's are made hollow by subtracting a constant from the abs value.
Loads of SDF functions and ways to modify them can be found eg. from here:
https://iquilezles.org/articles/distfunctions2d/
Support Staff 2 Posted by john on 09 May, 2024 07:13 PM
Jussi,
This is fascinating.
My understanding is that signed distance functions (SDFs) are primarily used to handle challenging real time animation like flickering flames. Flames represented as multiple vector paths with complex geometries which change second by second and merge and separate in near-infinite ways can be hard to compute at scale. SDFs provide a way of representing shapes not as distinct splines, but as emergent properties of a field of pixels, where each position in the field (each pixel) is given a number which can then code for color.
For this to provide a significant advantage and produce stunning results you need two things: high resolution and speed.
The speed is achieved by breaking shapes down into geometric primitives like circles and rectangles that lend themselves to simple distance calculations. The link you provided shows all sorts of clever examples where different shapes are reduced to relatively simple equations. Those shapes can then be displaced, rotated, rounded, and combined to produce all sorts of more complex shapes.
All this would seem far removed from NodeBox. We don't have pixels, and when we simulate them with dots or squares, we can only handle a 100,000 or so before things start to bog down.
And yet...
You have somehow managed to use SDFs to create some weird and wonderful creatures which seem perfect for plotters. I encourage anyone reading this to check out Jussi's Instagram page:
https://www.instagram.com/jussijokinen/
One quick tip about the two very helpful samples you provided. Inside your SDF functions you spend some effort using the Pythagorean theorem to compute distances before subtracting a value to make them signed. This is unnecessary. Nodebox has a standard distance node that will do this for you, saving not just effort but also maybe a few extra nanoseconds.
I have started looking into ways to provide a generic SDF node. Maybe give it a grid and a shape and it will returned a signed number for each point in the grid (assuming everything is centered on the origin - you can muck with that downstream if you need to).
Would that be useful?
Seeing your examples and the link you shared, I wonder if it would be. It seems like half the fun of SDFs, the way you are using them, is finding different primitives and combining them in clever ways. A generic node might not as much fun (even if it could handle arbitrary shapes like a lower case a).
All of this also inspires me to dust off my old attractor node, which has a certain similarity to your SDFs. I will poke around a bit and maybe come back with some more ideas.
You appear to already be making good progress playing with SDFs. Is there anything else in particular you would like some help with? Other wonders to share?
Keep it coming!
John
Support Staff 3 Posted by john on 10 May, 2024 11:50 AM
Jussi, All,
Progress!
I now have a general purpose SDF node that can handle any complex shape, including compound shapes and shapes with contours (holes) like letters. It takes a grid of points, a shape, and an origin (against which distance is measured). The shape does not have to be centered; if it's not you will probably want to set the origin to the centroid of the shape.
My demo gives you the option to show the outline of the shape and/or the distance numbers. It colors the insides of shapes (negative distances) from yellow at the edges to red in the center. It colors the outside from green through blue to violet at the extreme edges.
The bad news: it's slow. On my souped up Mac Pro it takes about 4 seconds to render a 32 x 32 pixel image (1024 pixels). A 100 x 100 image with 10,000 pixels takes 34 seconds!
So it's not going to win any races. But it does seem to work so far. I'll provide the node and more documentation after more testing (and more sleep).
Meanwhile, as proof of concept, two images attached:
John
Support Staff 4 Posted by john on 11 May, 2024 11:54 AM
More Progress!
If you look closely at the SDF image I attached yesterday, you will see some "color ridges", areas with sharp boundaries where some adjacent pixels have very different signed distances.
The reason for this is the way I defined distance: the distance from a pixel to the FIRST encounter with a shape edge or the origin, whichever comes first. This is a function that produces signed distances, but it's not the way most people would define it. I think most people would want the distance from a pixel to the nearest shape boundary (with no need to specify an "origin"). That definition produces smooth gradients without "ridges".
So I threw everything out and started over.
What I really needed, as I suspected from the outset, was my old attractor node, released in the first edition of my library and seldom used after that.
First I reworked it and broke it into two separate, more efficient nodes: attraction and angle_field. Attraction computes normalized minimum distance from points to a shape and can be set to make the shape attract or repel. Angle_field computes weighted average angles from points to a shape (using the three nearest points on a sampled shape's boundary).
By breaking the original node in two, I made each much faster.
Attraction node is a distance function, but all the distances are positive. Yesterday I made another new node, is_inside, which returns true if a given point is inside a shape. By adding that function to Attraction, I created another variant node: signed_dist. Signed_dist is even simpler than Attraction: it simply returns the raw signed distance from a point to a shape's edge (with a negative value if the point is inside the shape).
Attached is an image showing an arrow field overlayed with a coloring based on the output of signed_dist - both based on the shape of a bold Times Roman lower case in a 100 x 100 field. In addition to producing smooth gradients with no need to specify an origin, signed_dist runs about twice as fast as yesterday's attempt, rendering 10,000 pixels in about 15 seconds.
If you zoom into this PNG, you should be able to make out the distance values for each "pixel".
Signed_dist returns actual pixel-based distances; the bigger the canvas, the larger these values will be. It is not currently normalized in any way.
I have also made another version which returns normalized distances between 0 and 1 (for external pixels) and 0 and -1 (for internal pixels), based on the maximum external and internal values in the overall field. You can also "focus" the exterior and interior values separately to change how quickly the gradients change. Because it needs to know the min and max exterior and interior distances, you have to feed it the grid points all at once instead of one a time. And it's a tad slower.
Using the min and max distances in a given field is not the only way to normalize distance values. You could do it based on canvas size, or across multiple fields. Also, it's not clear to me that you even need to normalize these values, and if you do, whether that should be done inside the signed_distance node or outside it using a separate node. So which signed_dist node should I release? Both? Neither?
Which leads me to an even more fundamental question: do we even need a general purpose signed distance node? What kind of things would we do with it?
Jussi, I'm particularly interested in what kind of things YOU would do with it. Signed_dist may or may not be very helpful to you in making your charming "germ" creatures.
I am also curious why you do your calculations in a tiny 1 x 1 pixel field with microscopic shapes and then scale them up afterwards. The distance values you get seem normalized (between 0 and 1), but actually are not. They could be slightly greater than 1 in some cases.
What do you want to do with the signed distances you produce. Use them to make color gradients (colored pixel squares)? Use them to draw little line segments but only for external points? Use them in animations to simulate attraction and repulsion? Do you care if the distance are raw or normalized in some way?
I now have more nodes than I know what to do with. Any guidance as to what you (or anyone else reading this) actually wants would help me choose how best to package all this.
John
Support Staff 5 Posted by john on 11 May, 2024 12:05 PM
Oh, here is one more quick example showing the smoother gradients produced by my latest signed_dist node. This one shows 3 different shapes compounded into one and was done on a 200 x 200 field (40,000 pixels). Took about a minute on my Mac Pro.
Support Staff 6 Posted by john on 11 May, 2024 12:10 PM
And my last image of the night: an example of the attraction node, used to size dots based on how close they are to a shape boundary. You can "focus" dots to produce interesting effects.
7 Posted by Jussi Jokinen on 11 May, 2024 06:04 PM
John,
Again, marvellous work and I highly appreciate your thoroughness on this.
You asked why I do everything in tiny 1x1 square? Well, all examples what I've studied has been using that range, maybe that's why :) And it's pretty easy to understand, since .1 is always 10% of the full width.
I'm not sure if this is true, but I vaguely think that GLSL progamming uses 0-1 range so maybe that's the origin why people tend to use it. Also I think people who release their art on NFT platforms are quite concerned about responsiveness and keeping all calculations on 0-1 makes it easy to adjust to any output resolution...
The way I used SDFs for my germs is as follows: I generate 10-50 rectangular SDFs randomly and combine them by taking the minimum value of all SDFs on a given point. (As done in my second example). I then use smoothing formula to soften the outcome, otherwise SDF's would result in a quite sharp edges. I'm using regular easing functions for that.
Nothing gets drawn at this point, it's only playing with numbers.
Finally, I generate a set of paths using SDF numbers as "displacement map", so that I'll get a terrain map. It's quite fast since there's only maximum of hundreds of lines drawn, everything else is just underlying calculations.
(There's surely more to my germs, like wrapping that terrain around polar coordinates and so on, but basically it's like that)
Support Staff 8 Posted by john on 12 May, 2024 06:11 PM
Jussi,
Thank you! That was clear and very helpful!
I can see some value in GLSL programming for standardizing, and a 1x1 canvas is perhaps the simplest size to choose. Many other variables (like probabilities) also standardize on a 0 to 1 spectrum. I don't see any responsiveness advantages, at least in this case, but perhaps there are some when you get close enough to the chipset.
It won't work with my current signed_dist node, though. As part of my process, when finding minimum distances to arbitrary shape boundaries, I rely on Nodebox's resample node, which has a minimum 1 pixel length for sampling. I suppose I could resample by amount when shapes are under a certain size, but that could get tricky with some shapes. I know of at least one way of avoiding resampling, but it would be inefficient in Nodebox.
(In general I am cautious about working at such a fine scale because it increases the chance of rounding errors in calculations accumulating to a point where they start to do damage. Rounding errors in geometric calculations cause me endless trouble.)
I had hoped to publish my node last night, but discovered some problems with a few rare corner cases, so will have to keep pounding on it a little longer. Once I get it working well enough I will experiment to determine the lower limit on shape, canvas, and pixel size.
Your method of generating multiple SDFs and then taking minimum values is ingenious. I believe you can achieve the same effect by compounding all the multiple shapes (at macroscopic size) into a single shape (with contours) and running my generic signed_dist node on that just once.
Not that I expect you will see any reason to convert to my signed_dist node once you have it. You seem to be getting by just fine without it. But now that you have opened my eyes to this technique I see many other possible uses. So I hope this node will be a worthy addition to my library,
Thanks again! And stay tuned! More nodes and idle ramblings are on the way!
John
Support Staff 9 Posted by john on 16 May, 2024 02:11 AM
Jussi,
I just released my signed_dist node:
http://support.nodebox.net/discussions/show-your-work/731-signed-di...
Please have a look and tell me what you think. Thanks again for an interesting challenge.
Attached is an inkblot monster at 200 x 200.