14
\$\begingroup\$

If you have ever consumed liquid from a shallow cup in sunlight, you may have noticed a shape like the following at the bottom:

a heart shaped caustic in the bottom of a white mug

You might notice a nephroid shaped bright outline, a brighter area on the outside of the nephroid, and a diffuse bright spot extending from the cusp of the nephroid.

Your task is to draw this shape.

A clearer rendered example:

caustic

You can create this shape by bounding parallel horizontal rays off the inside of a circle and counting how many intersect near a point.

A diagram showing rays hitting the inner edge of a semicircle and bouncing off. The tangents create a nephroid shape.

Example code (GLSL):

#define PI 3.1415926538
#define STEPS 3200.0

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    // Normalized pixel coordinates (from 0 to 1)
    vec2 uv = fragCoord/iResolution.y * 2.0 - vec2(1.0, 1.0);

    if (length(uv) < 1.0) {
        float col = 0.0;
        for (float i = 0.0; i<STEPS; i+=1.0) {
            float y = i/(STEPS / 2.0) - 1.0;
            float x = sqrt(1.0 - y * y);
            float angle = asin(y) * 2.0;
            
            float slope = tan(angle);
            float dist = abs(slope * (uv.x - x) - (uv.y - y)) / sqrt(slope * slope + 1.0);
            
            if (dist < 0.005) {
                col+=0.005 - dist;
            }
        }

        fragColor = vec4(col,col*0.5,col*0.25,1.0);
    } else {
        fragColor = vec4(1.0, 1.0, 1.0, 1.0);
    }
}

Rules:

  • Minimum resolution is 512x512
  • Use at least 16 distinct colors. You are free to pick the color scheme.
  • All three aspects must be clearly visible: Nephroid rim, bright area on the outside, diffuse bright area near the cusp.
  • If you use the ray-based approximation, you must use at least 400 rays. You are welcome to use an alternative approach than drawing discrete rays.
  • Area outside the circle is undefined behavior, it can be any color or pattern

As is standard with , you can save the image to a file using any reasonable image format, display it on the screen, or print a representation to the terminal using ANSII escape codes to set the color.

\$\endgroup\$
4
  • \$\begingroup\$ Desmos might be useful. \$\endgroup\$ Commented May 5, 2025 at 23:52
  • 1
    \$\begingroup\$ Desmos \left(x^{2}+y^{2}-4\right)^{3}=108y^{2} draws a nephroid, but as far as i can tell there's no way to meet the challenge specs because desmos can't even draw gradients. @Lucenaposition \$\endgroup\$ Commented May 7, 2025 at 9:46
  • 1
    \$\begingroup\$ Well, desmos CAN do gradients, I have a ray-based desmos answer that does, but unfortunately the desmos scoring consensus gives no way to count things like changing line opacity (which can't be done "programatically"), without which it still fails the 16 color requirement. \$\endgroup\$ Commented May 7, 2025 at 18:54
  • 2
    \$\begingroup\$ @CursorCoercer I'm interested to see your solution. Feel free to score in a custom scheme that you think is fair and fun, we shouldn't ban answers just because the current scoring does not cover some edge case \$\endgroup\$ Commented May 8, 2025 at 6:55

3 Answers 3

11
\$\begingroup\$

Desmos, 43 + 3 = 46 bytes

t=[-4^4...4^4]
y\sqrt{4^8-tt}=x8^5/t-xt+8^5

Try it online!

This is a ray based approach that uses 512 rays to approximate the caustic.

Demonstration of the code

Explanation

Not too much to explain here, t is a list defined to be the range from -256 to 256 inclusive. And our second expression is just a golfed equation of the relevant reflected ray on a circle of radius 256 that intersects the circle at x=t. Because of the golfing done, when t=0 the resulting vertical line doesn't render (since a divide by zero occurs) but desmos handles this gracefully and the effect on the resulting image is not particularly bad. This becomes unnoticeable at higher ray counts.

Scoring

For those unaware, desmos scoring is based on the byte count of text that can be pasted into the expression box as discussed here. This is sufficient for most desmos answers and in general is a good and rigorous way of having "byte counts" for desmos graphs. But as discussed here, this neglects many of desmos' configurable graphical features such as setting the colors of expressions, or importantly for this challenge, setting line opacity. Thus with the current generally accepted rules desmos is unable to complete this challenge as the lines are fully opaque by default leaving only 2 colors in the rendered image.

That being said, I have been given permission by mousetail to make up my own rules for scoring desmos for this challenge! Thus I propose the following: For necessary setting changes the byte count to be added should be the minimum number of bits needed to set the configuration, rounded up to the nearest byte, plus one, for each setting configured. The plus one is a sort of "boundary byte", we can imagine a "meta desmos language" that consists of the code to pasted in followed by a list of settings information, where each chunk of settings information has a special leading byte to identify what setting it's for. In my case here, my settings information is setting the line opacity to .1 in the first (and only) expression. Thus I need 2+1=3 extra bytes to encode this information.

There is some more discussion to be had about desmos byte counts, and this slippery slope is likely the reason why a scheme like mine above has not been adopted. For the purposes of this challenge I'm considering the configuration of the graph's viewing window to not be a part of the byte count, but here there are some open questions. Should the grid lines and axes be counted as a part of the rendered image or are they simply a background artifact? Certainly they can be turned off, and under my scheme above would cost 2 bytes each to do so, meaning another +6 for this answer. But they also aren't even static with the viewing window and really feel more like a HUD than a part of the rendering. This brings us to a possibly even more pressing question. Is the viewing window's position/zoom part of the rendering? Again I haven't counted it here, for this answer all you need to do is zoom out to see the whole image. However, there are a couple of compelling reasons that if it's not in the correct position by default it should be counted. For one, desmos' veiwing window allows the aspect ratio to be changed arbitrarily, even in this answer I could save a byte or two by squishing the y-axis down allowing my expression to draw the caustic in an ellipse rather than a circle. The other problem is that if there's no penalty for windows movement one can essentially beat any graphical challenge just by going to the right place in Tupper's formula. But there are problems with counting it too. Namely, depending on the the aspect ratio of your screen, putting in the same data into the axis limits yields different views. Thus I think it makes sense to not include these viewing conditions in the byte count, and just have reasonable expectations (including no custom aspect ratios) about the amount of window movement needed to see the rendered image.

\$\endgroup\$
5
\$\begingroup\$

Pug/HTML 162 161 bytes

- k=999,i=0,p=" ",M=Math
svg(viewBox=-k+p+-k+p+2*k+p+2*k): while i<k
 line(x1=M.sin(a=M.PI/k*i++)*k y1=M.cos(a)*k x2=M.sin(b=3*a)*k y2=M.cos(b)*k stroke="#0003")

Try it online!

The compiled code is not a valid standalone SVG file, but needs to be rendered by a HTML browser. The result is scalable and has no fixed size, it rather depends on the available viewport.

a caustic with black lines on white background


For the price of 22 bytes of CSS, you can get a rendering with light on black: Codepen. I'm adding it only because I think it looks awesome.

svg{background:#000}

...and for 14 bytes more, you get a round area:

svg{background:#000;border-radius:50%}
\$\endgroup\$
3
\$\begingroup\$

Python + matplotlib, 192 188 bytes

Edit:

from math import*
from matplotlib.pyplot import*
R=450
for x in range(-R,R+1):
 if x:y=sqrt(R*R-x*x);t=tan(2*atan(y/x)-3*pi/2);plot((x-y/t,x+(R-y)/t),(0,R),'b',lw=.1)
xlim(-R-9,R+9)
show()

(Can't) Attempt This Online!

Example graphical output: Graphical output


Explanation

  • Consider half circumference and loop on x coordinates: at each coordinate consider a vertical line intersecting the circumference (exclude x == 0 to avoid infinite slope error).
  • Compute the tangent to the circumference and from this the slope of the reflection of the vertical line on the circumference.
  • Plot all the reflected lines.
\$\endgroup\$
1
  • 1
    \$\begingroup\$ 188. if x: is a valid statement in Python 2 (and works for floats as well as ints), and you can also write 0.1 as .1 \$\endgroup\$ Commented May 13, 2025 at 14:58

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.