Additional Examples

This notebook contains more advanced examples using pySSV.

[ ]:
# Google colab support
try:
    # Try enabling custom widgets, this will fail silently if we're not in Google Colab
    from google.colab import output
    output.enable_custom_widget_manager()
    # Install pySSV for this session
    %pip install pySSV
except:
    pass

Video

This example takes advantage of the point cloud shader template to render video in real time. In this case, the video is compressed into a quadtree (this is obviously not a very good compression algorithm for video, but it’s easy to encode/decode so it makes for a good demonstration) which is stored in a texture. Each row of the animation texture stores the quadtree for one frame where each pixel is one cell in the quadtree. The way this is implemented means the cells don’t strictly need to be part of a quad tree, they just each represent a single square of a given size, colour, and location.

The video quadtree is created by a separate program the code for which can be found here: https://github.com/space928/badapple-quadtree-encoder

[ ]:
import os.path

# Download the compressed video file from the internet if needed (with the user's permission)
filename = "badapple_quad.pkl"
if not os.path.isfile(filename):
    if input("Encoded video file not found! Do you want to download it now (yes/no)?")[0] == "y":
        url = "https://github.com/space928/badapple-quadtree-encoder/releases/download/0.1.0/badapple_quad.pkl"
        import urllib.request
        try:
            print("Downloading...")
            urllib.request.urlretrieve(url, filename)
            print("Successfully downloaded encoded video file!")
        except Exception as e:
            print(f"Failed to download video: {e}")
else:
    print(f"Video file '{filename}' already exists, using existing version...")
[ ]:
import pySSV as ssv
import numpy as np
import pickle as pkl

canvas5 = ssv.canvas(use_renderdoc=True)
with open("badapple_quad.pkl", "rb") as f:
    anim, frame_lengths = pkl.load(f)
    print(f"Loaded animation! Animation has shape:{anim.shape}")

canvas5.main_render_buffer.full_screen_vertex_buffer.update_vertex_buffer(np.zeros((anim.shape[0]*6), dtype=np.float32))

anim = np.swapaxes(anim, 0, 1)
# Dcelare textures, make sure that these textures as treated as ints instead of floats
anim_tex = canvas5.texture(anim, "uAnimTex", treat_as_normalized_integer=False)
frame_lengths_tex = canvas5.texture(frame_lengths, "uFrameLengthsTex", treat_as_normalized_integer=False)
# Setup texture samplers
anim_tex.repeat_x, anim_tex.repeat_y = False, False
anim_tex.linear_filtering = False
frame_lengths_tex.repeat_x, frame_lengths_tex.repeat_y = False, False
frame_lengths_tex.linear_filtering = False

canvas5.shader("""
#pragma SSV point_cloud mainPoint --non_square_points
// These are automatically declared by the preprocessor
//uniform isampler2D uAnimTex;
//uniform isampler2D uFrameLengthsTex;
VertexOutput mainPoint()
{
    VertexOutput o;
    // Synchronise the playback to the time uniform, 30 FPS
    int frame = int(uTime*30.-20.);

    int frameLen = texelFetch(uFrameLengthsTex, ivec2(0, frame), 0).r;
    if(gl_VertexID > frameLen)
    {
        // Early out for verts not needed in this frame; no geometry will be generated for these as the size is set to 0
        o.size = vec2(0.);
        return o;
    }
    // This contains the data for the current quad to rendered (value (0-255), x (pixels), y (pixels), subdivision (0-n))
    ivec4 quad = texelFetch(uAnimTex, ivec2(gl_VertexID, frame), 0);
    // The size is determined by the subdivision level of the cell in the quad tree.
    o.size = vec2(1./pow(2., quad.w-0.1));
    if(quad.w == 0)
        o.size = vec2(0.);
    vec4 pos = vec4(float(quad.z)/480., 1.-float(quad.y)/360., 0., 1.);
    pos.xy += o.size/vec2(2., -2.);  // Centre the point
    pos = pos*2.-1.;  // To clip space (-1 to 1)
    pos += vec4(in_vert, 0.)*1e-8;  // If in_vert is not used, the shader compiler optimises it out which makes OpenGL unhappy; this may be fixed in the future
    o.position = pos;
    o.color = vec4(vec3(float(quad.x)/255.0)+in_color, 1.0);
    return o;
}
""")
canvas5.run()

Geometry shaders

This shader demonstrates the use of custom geometry shaders to render a vector field.

[ ]:
import pySSV as ssv
import numpy as np

# Generate some points
def generate_points():
    width, depth = 64, 64
    scale = 3
    v_scale = 0.5
    f = 0.01
    verts = np.zeros((width, depth, 9), dtype='f4')
    for z in range(depth):
        for x in range(width):
            dx = width/2 - x
            dz = depth/2 - z
            y = np.sin((dx*dx+dz*dz)*f) * v_scale
            # Pos
            verts[z, x, :3] = [x/width * scale, y, z/depth * scale]
            # Colour
            verts[z, x, 3:6] = [y/v_scale, abs(y/v_scale), np.sin(y/v_scale*10.)*0.5+0.5]
            # Direction
            verts[z, x, 6:9] = [dx/width, 0.1, dz/depth]

    return verts.flatten()

canvas5 = ssv.canvas(use_renderdoc=True)
# Set the contents of default vertex buffer on the main pass (normally used for full-screen shaders, but in this case hijacked for this example)
canvas5.main_render_buffer.full_screen_vertex_buffer.update_vertex_buffer(generate_points(), ("in_vert", "in_color", "in_dir"))
canvas5.main_camera.target_pos = np.array((1.5, 0, 1.5))
#print(canvas5.dbg_preprocess_shader("""
canvas5.shader("""
#pragma SSV geometry mainPoint mainGeo --vertex_output_struct VertexOutput --geo_max_vertices 7 --custom_vertex_input
struct VertexOutput {
    vec4 position;
    vec4 color;
    vec3 dir;
    float size;
};

#ifdef SHADER_STAGE_VERTEX
in vec3 in_vert;
in vec3 in_color;
in vec3 in_dir;

VertexOutput mainPoint()
{
    VertexOutput o;
    vec4 pos = vec4(in_vert, 1.0);
    //pos = uViewMat * pos;
    //pos = uProjMat * pos;
    o.position = pos;
    o.color = vec4(in_color, 1.);
    o.size = 30.0/uResolution.x;
    o.dir = normalize(in_dir);
    return o;
}
#endif

#ifdef SHADER_STAGE_GEOMETRY
void mainGeo(VertexOutput i) {
    vec4 position = i.position;
    float size = i.size;
    // This output variable is defined by the template and must be written to before the first EmitVertex() call to take effect
    out_color = i.color;
    vec3 fwd = normalize((uViewMat * vec4(0., 0., 1., 0.)).xyz);
    vec3 perp = normalize(cross(i.dir, fwd));
    vec4 aspect_ratio = vec4(1., uResolution.x/uResolution.y, 1., 1.);
    float baseWidth = 0.05;
    float headWidth = 0.2;
    float headLength = 0.4;
    // Now we draw an arrow
    // Base
    out_color = vec4(0.,0.,0.,1.);
    gl_Position = position + size * vec4(perp*baseWidth, 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    gl_Position = position + size * vec4(-perp*baseWidth, 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    out_color = i.color;
    gl_Position = position + size * vec4(i.dir + perp*baseWidth, 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    gl_Position = position + size * vec4(i.dir - perp*baseWidth, 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    EndPrimitive();
    // Head
    gl_Position = position + size * vec4(i.dir + perp*headWidth, 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    gl_Position = position + size * vec4(i.dir + -perp*headWidth, 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    gl_Position = position + size * vec4(i.dir * (1.+headLength), 0.0) * aspect_ratio;
    gl_Position = uProjMat * uViewMat * gl_Position;
    EmitVertex();
    EndPrimitive();
}
#endif
""")#)
canvas5.run(stream_quality=100)

Streaming Modes

pySSV supports a number of different video streaming modes to get rendered frames from the OpenGL backend into Jupyter. They each have their own advantages and disadvantages, so you can experiment with which method works best for you. JPG should be supported everywhere, but if your platform supports it, I would recommend VP8 or MJPEG.

Not all streaming modes are supported on all platforms. Google Colab is notoriously difficult to get working nicely.

Here we present a particularly difficult example for video encoders, a point cloud (taken from the introduction.ipynb notebook) and how the different encoding settings affect it.

The following streaming modes are supported:

  • JPG

  • PNG

  • VP8

  • VP9

  • H264

  • MJPEG

The streaming mode is controlled using the stream_mode parameter of the canvas.run() method which accepts a str or an SSVStreamingMode (from pySSV.ssv_render_process_server import SSVStreamingMode). The run() method also takes a stream_quality parameter which can be used to control the compression of the encoder. It accepts a value from 0-100 (some encoders will work with values greater than 100, others clamp it) which, depending on the encoder, is scaled to give the constant bit rate or quality factor. Higher values give better quality images at the cost of higher bandwidth utilisation. When the stream_quality is above or equal to 90, chroma subsampling is disabled for formats that support yuv444p.

Technical Details

Internally, pySSV opens a dedicated websocket with the Jupyter frontend to stream video. On platforms where this isn’t supported (notably, Google Colab) this falls back to using Jupyter Widget messages which are a bit less efficient due to the protocol’s need to json encode everything. The MJPEG format is an exception to this as it communicates using a local HTTP server, relying on the browser’s native support for MJPEG over HTTP; this has the advantage that MJPEG frames don’t need to be json encoded or parsed in JS which helps a lot with latency.

The image formats JPG and PNG are encoded using Pillow as base64 encoded data URLs which are passed to an <img>. Whereas as the video formats are encoded by libavformat (FFmpeg’s encoding library) and decoded in javascript using the WebCodecs API and blitted to a canvas; hence the lack of support for Firefox for these formats. MJPEG is encoded by libavformat and passed directly as a URL to the local HTTP server to an <img>.

[ ]:
import pySSV as ssv
import numpy as np

# Make the canvas size a bit bigger to put a bit more pressure on the encocers
CANVAS_SIZE = (1280, 720)

# Generate some points
def generate_points():
    width, depth = 64, 64
    scale = 3
    v_scale = 0.5
    f = 0.01
    verts = np.zeros((width, depth, 6), dtype='f4')
    for z in range(depth):
        for x in range(width):
            dx = width/2 - x
            dz = depth/2 - z
            y = np.sin((dx*dx+dz*dz)*f) * v_scale
            verts[z, x, :3] = [x/width * scale, y, z/depth * scale]
            verts[z, x, 3:6] = [y/v_scale, abs(y/v_scale), np.sin(y/v_scale*10.)*0.5+0.5]

    return verts.flatten()

def make_canvas():
    canvas = ssv.canvas(use_renderdoc=True, size=CANVAS_SIZE)
    # Set the contents of default vertex buffer on the main pass (normally used for full-screen shaders, but in this case hijacked for this example)
    canvas.main_render_buffer.full_screen_vertex_buffer.update_vertex_buffer(generate_points())
    canvas.main_camera.target_pos = np.array((1.5, 0, 1.5))
    canvas.shader("""
    #pragma SSV point_cloud mainPoint
    VertexOutput mainPoint()
    {
        VertexOutput o;
        vec4 pos = vec4(in_vert, 1.0);
        pos = uViewMat * pos;
        pos = uProjMat * pos;
        o.position = pos;
        o.color = vec4(in_color, 1.);
        o.size = 30.0/uResolution.x;
        float d = length(uMouse/uResolution.xy*2.-1.-pos.xy/pos.z);
        o.size += clamp(pow(smoothstep(.5, 0., d), 3.)*0.03, 0., 0.3);
        o.color += step(d, o.size);
        return o;
    }
    """)
    return canvas

The default settings when canvas.run() is called are stream_mode="jpg" and stream_quality=75. When stream_quality is unset it defaults to the encoder’s default quality.

[ ]:
make_canvas().run()
[ ]:
# For JPG streams, setting the stream quality to 100 can actually *improve* encoding performance (if not limited by bandwidth) as some of the optimisations can be skipped.
make_canvas().run(stream_quality=100)
[ ]:
# For PNG is always lossless so the stream quality can't be controlled.
# This format is currently the only one which supports transparency in the output.
# It's also VERY slow to encode.

# With streaming formats the produce very large frames, such as png, Jupyter/the web browser can
# get backed up with frames, in this case the frame rate may still be reasonable, but extremely
# high latency (and memory usage!) will be apparent. In this case you need to switch to a streaming
# format that offers more compression or decrease the streaming quality.

make_canvas().run(stream_mode="png")

Video Formats

[ ]:
# VP8 offers a good balance between quality and encoding time while offering very good compression
# Latency is also generally fairly low
make_canvas().run(stream_mode="vp8", stream_quality=100)
[ ]:
# VP9 has improved compression efficiency but is much slower at encoding
make_canvas().run(stream_mode="vp9", stream_quality=100)
[ ]:
# H264 is fast to encode, but the compressions isn't quite as efficient as VP8
make_canvas().run(stream_mode="h264", stream_quality=100)
[ ]:
# MJPEG has very low latency and fast encoding/decooding time, but worse compression efficiency than other video formats.
make_canvas().run(stream_mode="mjpeg", stream_quality=100)

Heightmap Demo

This example downloads DEM (digital elevation model) data from an online API and renders it in 3d using a shader.

The DEM data in question is derived from the SRTM1 (https://www2.jpl.nasa.gov/srtm/) dataset which covers most of the world (from 56°S to 60°N) at a resolution of 1 arc second (roughly 30m at the equator). Voids, trees, and buildings in the dataset have been removed.

The API returns a single tile which covers 1 degree by 1 degree. Special thanks to Adam Mathieson (https://github.com/amathieson) for hosting and maintaining this API, it is only to be used for the purpose of this demo.

The colouring of the data is purely for aesthetic interest.

[ ]:
# Select a point of interest
lat, lon = 46.2059915, 6.1475919  # Geneva, Switzerland
poi_name = "Geneva"

# Glasgow doesn't work very well...
# lat, lon = 55.8579612,-4.2582393  # Glasgow, Scotland
# poi_name = "Glasgow Central Station"

lat, lon = 35.36283,138.7312618  # Mount Fuji, Japan
poi_name = "Mount Fuji"
[ ]:
import math
import zlib
from PIL import Image
import os.path
import numpy as np
import pySSV as ssv

# Download the needed DEM tiles
# This snippet is derived from code written by Adam Mathieson, reused with permission
api_url = "https://cdn.whats-that-mountain.site/"
def latlon2ne(lat, lon):
    return f"{'s' if lat<0 else 'n'}{abs(math.floor(lat)):02d}{'w' if lon<0 else 'e'}{abs(math.floor(lon)):03d}"

def get_tile(lat, lon):
    tile_name = f"{latlon2ne(lat, lon)}.hgt.gz"
    if not os.path.isfile(tile_name):
        import urllib.request
        try:
            print(f"Downloading {api_url + tile_name}...")
            opener = urllib.request.URLopener()
            opener.addheader('User-Agent', 'python-ssv-demo')
            opener.retrieve(api_url + tile_name, tile_name)
            print("Done!")
        except Exception as e:
            print(f"Failed to heightmap tile: {e}")
    return tile_name

corners = [
    (lat+.5, lon-.5),  # top left
    (lat+.5, lon+.5),  # top right
    (lat-.5, lon-.5),  # bottom left
    (lat-.5, lon+.5),  # bottom right
]

# Open and decompress the tile
tile_name = get_tile(lat, lon)
with open(tile_name, "rb") as f:
    data = zlib.decompress(f.read())
    tile = np.array(np.frombuffer(data, np.int16))
    tile = tile.byteswap(inplace=True)
    tile = tile.reshape((3601, 3601))
    # print(tile)
[ ]:
# Create a new canvas and shader to render the tile
canvas = ssv.canvas(use_renderdoc=True)
tex = canvas.texture(tile, "uHeightMap", force_2d=True)
tex.repeat_x, tex.repeat_y = True, True
tex.linear_filtering = True

# In this example we use a simple SDF to view the heightmap in 3D
canvas.shader("""
#pragma SSV sdf sdf_main --render_mode SOLID --camera_mode INTERACTIVE

float sdf_main(vec3 p) {
    // Sample the heightfield and scale it as desired
    float disp = texture(uHeightMap, fract(p.xz*0.1+.5)).r*5.+.3;
    // Return the signed distance to the surface. Note that this is not an exact SDF and fails anywhere where
    // the steepness exceeds 67.5deg. The final *.5, scales the distance field such that steeper angles can
    // be used (67.5deg instead of 45deg) at the cost of more marching steps be required.
    return (p.y-disp)*.5;
}
""")
canvas.run()

Now we try rendering the heightfield as a mesh:

[ ]:
# Generate the mesh vertices
res = 3601
res = res//4  # Reduce vertex count for performance

# Make a grid of x, y, r, g, b
lin = np.linspace(0, 1, res)
zeros = np.zeros((res, res))
x,y = np.meshgrid(lin, lin)
verts = np.dstack((x,y,zeros, zeros,zeros))
# print(verts.shape)

# Define the triangles for the grid
inds = np.arange(0, res*(res-1))  # Create an array of indexes (skipping the last row of vertices)
inds = inds[(inds+1)%res!=0]  # Now skip the last column of vertices
inds = np.dstack((inds, inds+1, inds+res, inds+1, inds+res+1, inds+res))  # Now create the indices for a quad for each point. A quad is defined by the indices (n, n+1, n+w, n+1, n+w+1, n+w)
# print(inds)

inds = np.array(inds.flatten(), dtype=np.int32)
verts = np.array(verts.flatten(), dtype=np.float32)
[ ]:
# Create a new canvas and shader to render the tile
canvas = ssv.canvas((1280, 720), use_renderdoc=True)

# Bind the texture
tex = canvas.texture(tile, "uHeightMap", force_2d=True)
tex.repeat_x, tex.repeat_y = False, False
tex.linear_filtering = True

# Update the vertex buffer with a grid of vertices
vb = canvas.main_render_buffer.vertex_buffer()
vb.update_vertex_buffer(verts, index_array=inds)

# Create a GUI to interact with the example, see the gui_examples notebook for more info on using GUIs
from pySSV.ssv_gui import create_gui, SSVGUI
from pySSV import ssv_colour
class MyGUI:
    slider_vertical_scale = 2.2
    slider_sun_p = 50.
    slider_sun_h = 180.
    slider_snow = 40.
    slider_dbg = 1.

    def on_gui_draw(self, gui: SSVGUI):
        gui.begin_vertical(pad=True)
        gui.rounded_rect(ssv_colour.ui_base_bg, overlay_last=True)
        gui.button("pySSV DEM Terrain Demo", ssv_colour.orange)
        self.slider_vertical_scale = gui.slider(f"Vertical scale: {float(self.slider_vertical_scale):.3f}",
                                                self.slider_vertical_scale, min_value=0, max_value=50, power=3.)
        self.slider_sun_p = gui.slider(f"Sun pitch: {float(self.slider_sun_p):.3f}",
                                       self.slider_sun_p, min_value=0, max_value=90)
        self.slider_sun_h = gui.slider(f"Sun heading: {float(self.slider_sun_h):.3f}",
                                       self.slider_sun_h, min_value=0, max_value=360)
        self.slider_snow = gui.slider(f"Snow height: {float(self.slider_snow):.3f}",
                                      self.slider_snow, min_value=0, max_value=100, power=3.)
        self.slider_dbg = gui.slider(f"Debug: {float(self.slider_dbg):.3f}",
                                      self.slider_dbg, min_value=0, max_value=10, power=3.)

        gui.space(height=30)
        gui.end_vertical()
        horiz_scale = 2.
        x, z = ((lat-math.floor(lat))*2.-1.)*horiz_scale, -((lon-math.floor(lon))*2.-1.)*horiz_scale
        # print(z, x)
        gui.label_3d(poi_name, (x, 0.025, z), font_size=12., shadow=True)

    def on_post_gui(self, gui: SSVGUI):
        gui.canvas.update_uniform("uVerticalScale", float(self.slider_vertical_scale))
        gui.canvas.update_uniform("uSunPitch", float(self.slider_sun_p))
        gui.canvas.update_uniform("uSunHeading", float(self.slider_sun_h))
        gui.canvas.update_uniform("uSnowHeight", float(self.slider_snow))
        gui.canvas.update_uniform("uDebug", float(self.slider_dbg))

gui = create_gui(canvas)
my_gui = MyGUI()
# Register a callback to the on_gui event
gui.on_gui(lambda x: my_gui.on_gui_draw(x))
gui.on_post_gui(lambda x: my_gui.on_post_gui(x))
canvas.update_uniform("uVerticalScale", my_gui.slider_vertical_scale)

# Create a shader to render the sky
canvas.shader("""
#pragma SSV pixel pixel

uniform float uSunPitch;
uniform float uSunHeading;
uniform float uDebug;

const mat3 xyzToSrgb = mat3 (
     3.24100323297636050, -0.96922425220251640,  0.05563941985197549,
    -1.53739896948878640,  1.87592998369517530, -0.20401120612391013,
    -0.49861588199636320,  0.04155422634008475,  1.05714897718753330
);

vec3 reinhard2(vec3 x) {
    const float L_white = 4.0;
    return (x * (1.0 + x / (L_white * L_white))) / (1.0 + x);
}

vec4 pixel(vec2 fragCoord) {
    vec2 uv = (fragCoord/uResolution.xy)*2.-1.;
    vec3 eye = normalize((vec4(uv, -1., 0.) * uViewMat).xyz);

    float sp = cos(radians(uSunPitch));
    vec3 sun = vec3(cos(radians(uSunHeading))*sp, sin(radians(uSunPitch)), sin(radians(uSunHeading))*sp);
    float mie = pow(max(dot(eye, sun), 0.), 4.);

    float p = (1.-sp)*0.5+0.25;
    vec2 g = pow(.8-max(eye.yy, 0.)*0.5, vec2(3.0+p*0.1, 3.0));
    vec3 col = xyzToSrgb * vec3(g.x, g.y, pow((1.-abs(eye.y)), 2.)*p+p);
    col += vec3(0.95, 0.93, 0.9) * mie*0.5;
    col = smoothstep(0., 1., reinhard2(col)*1.8);

    return vec4(col, 1.);
}
""")

# Create a shader to render the terrain
vb.shader("""
#pragma SSV vert_pixel vert pixel

uniform float uVerticalScale;
uniform float uSunPitch;
uniform float uSunHeading;
uniform float uSnowHeight;
uniform float uDebug;

const float horizScale = 2.;
const float texelSize = 1./3601.;

#ifdef SHADER_STAGE_VERTEX
layout(location = 3) out vec2 uv;

void vert() {
    uv = in_vert.xy;
    uv = 1.-uv;
    uv = uv.yx;
    float disp = texture(uHeightMap, uv.xy).r;
    vec2 v = (in_vert.xy*2.-1.)*horizScale;
    gl_Position = vec4(v.x, disp*uVerticalScale, v.y, 1.);
    gl_Position = uProjMat * uViewMat * gl_Position;
}
#endif

#ifdef SHADER_STAGE_FRAGMENT
layout(location = 3) in vec2 uv;

float radians(float x) {
    return x/180.*3.14159265;
}

vec3 reinhard2(vec3 x) {
    const float L_white = 4.0;
    return (x * (1.0 + x / (L_white * L_white))) / (1.0 + x);
}

// BRDF functions taken from:
// https://www.shadertoy.com/view/XlKSDR
float pow5(float x) {
    float x2 = x*x;
    return x2*x2*x;
}
float dGGX(float linearRoughness, float ndoth, const vec3 h) {
    // Walter et al. 2007, "Microfacet Models for Refraction through Rough Surfaces"
    float oneMinusNoHSquared = 1.0 - ndoth * ndoth;
    float a = ndoth * linearRoughness;
    float k = linearRoughness / (oneMinusNoHSquared + a * a);
    float d = k * k * (1.0 / 3.141592);
    return d;
}
float vSmithGGXCorrelated(float linearRoughness, float NoV, float NoL) {
    // Heitz 2014, "Understanding the Masking-Shadowing Function in Microfacet-Based BRDFs"
    float a2 = linearRoughness * linearRoughness;
    float GGXV = NoL * sqrt((NoV - a2 * NoV) * NoV + a2);
    float GGXL = NoV * sqrt((NoL - a2 * NoL) * NoL + a2);
    return 0.5 / (GGXV + GGXL);
}
vec3 fSchlick(const vec3 f0, float VoH) {
    // Schlick 1994, "An Inexpensive BRDF Model for Physically-Based Rendering"
    return f0 + (vec3(1.0) - f0) * pow5(1.0 - VoH);
}

vec4 pixel(vec3 pos) {
    float h = texture(uHeightMap, uv).r;
    // Compute the normals by finite differences
    vec3 nrm = vec3(0.);
    nrm.z = h - texture(uHeightMap, uv - vec2(texelSize, 0.)).r;
    nrm.x = h - texture(uHeightMap, uv - vec2(0., texelSize)).r;
    nrm.xz *= uVerticalScale/horizScale/texelSize/2.;
    nrm.y = 1.;//-sqrt(nrm.x*nrm.x + nrm.z*nrm.z);
    nrm = normalize(nrm);

    // Lighting
    float sp = cos(radians(uSunPitch));
    vec3 sun = vec3(cos(radians(uSunHeading))*sp, sin(radians(uSunPitch)), sin(radians(uSunHeading))*sp);
    vec2 screenPos = (gl_FragCoord.xy/uResolution.xy*2.-1.) * vec2(1., uResolution.y/uResolution.x);
    vec3 eye = normalize((vec4(screenPos, 1., 0.) * uViewMat).xyz);
    float ndotl = dot(nrm, sun);
    float ndotv = dot(nrm, eye);
    vec3 half = normalize(eye + sun);
    float ndoth = clamp(dot(nrm, half), 0., 1.);
    vec3 sunCol = pow(vec3(1., 0.9, 0.75), vec3(sp*sp*sp*2.5+1.));

    // Texturing
    const vec3 water = vec3(0, 40./255., 49./255.)*1.2;
    const vec3 city = vec3(101./255, 115./255., 88./255.)*1.2;
    const vec3 forest = vec3(10./255., 42./255., 24./255.)*1.2;
    const vec3 cliff = vec3(214./255., 220./255., 173./255.)*.5;
    const vec3 snow = vec3(247./255., 247./255., 255./255.)*1.2;

    float water_city = smoothstep(11.034/1000., 12.052/1000., h);
    float city_forest = smoothstep(10.4/1000., 24./1000., h);
    float forest_cliff = smoothstep(0.6, 0., abs(nrm.y));
    float cliff_snow = smoothstep((uSnowHeight)/1000., (45.+uSnowHeight)/1000., h) * smoothstep(0.3, 0.5, abs(nrm.y));
    vec3 alb = water;
    alb = mix(alb, city, water_city);
    alb = mix(alb, forest, city_forest);
    alb = mix(alb, cliff, forest_cliff);
    alb = mix(alb, snow, cliff_snow);

    // Specular
    float rough = clamp((alb.g*0.6+water_city*0.9)*0.7*uDebug, 0., 1.);
    rough *= rough;
    float specD = dGGX(rough, ndoth, half);
    specD *= vSmithGGXCorrelated(rough, clamp(ndotv, 0., 1.), clamp(ndotl, 0., 1.));
    vec3 spec = vec3(specD);
    spec *= fSchlick(vec3(0.04), clamp(dot(eye, half), 0., 1.));

    // Composition
    vec3 col = alb;
    col *= (ndotl*.5+.5) * sunCol;
    col += clamp(spec, 0., 10.) * clamp(ndotl, 0., 1.) * sunCol;
    col *= 2.;
    col = smoothstep(0., 1., reinhard2(col)*1.2);
    //col = vec3(rough);

    return vec4(col, 1.);
}
#endif
""")
canvas.run(stream_quality=90)
[ ]:

[ ]: