PyScript, an innovative framework, bridges the gap between Python’s simplicity and the vast capabilities of web technologies. Leveraging WebAssembly and Pyodide, PyScript offers an intriguing approach to web application development, particularly for complex tasks like 3D rendering using WebGL. This article explores the fascinating world of 3D graphics in the browser, utilizing the PyScript framework and WebGL capabilities through a hands-on example.

What is WebGL and Its Significance in Python-powered Web Development?

WebGL (Web Graphics Library) is a JavaScript API for rendering high-performance interactive 3D and 2D graphics within any compatible web browser without the use of plug-ins. By combining Python’s accessibility with WebGL’s rendering power, developers can create sophisticated 3D visualizations directly in the browser.

Introducing the webgl.html Example

Our webgl.html example demonstrates how Python, through PyScript, can be used to create stunning 3D graphics using WebGL. The example showcases a dynamic 3D scene with moving particles and cubes, all rendered in real-time within the browser. It’s an excellent illustration of how Python’s syntax and libraries, combined with WebGL’s graphical prowess, can result in visually appealing and interactive web applications.

The HTML Setup for 3D Graphics:

The listing below uses PyScript’s original <py-script> element, which is the form most tutorials and Stack Overflow answers still show. It no longer runs as written — the loader URL it depends on has been retired. It is worth reading through first because the Python inside it is unchanged, and then the current equivalent is a few lines of markup away.

<html lang="en">
        <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.147.0/three.min.js"></script>

        <script defer src="https://pyscript.net/latest/pyscript.js"></script>
        <link
            rel="stylesheet"
            href="https://pyscript.net/latest/pyscript.css"
        />
        <py-script>
            from pyodide.ffi import create_proxy, to_js
            from js import window
            from js import Math
            from js import THREE
            from js import performance
            from js import Object
            from js import document
            import asyncio

            mouse = THREE.Vector2.new();

            renderer = THREE.WebGLRenderer.new({"antialias":True})
            renderer.setSize(1000, 1000)
            renderer.shadowMap.enabled = False
            renderer.shadowMap.type = THREE.PCFSoftShadowMap
            renderer.shadowMap.needsUpdate = True

            document.body.appendChild( renderer.domElement )

            import js, pyodide
            def onMouseMove(event):
              event.preventDefault();
              mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
              mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
            js.document.addEventListener('mousemove', pyodide.ffi.create_proxy(onMouseMove))

            camera = THREE.PerspectiveCamera.new( 35, window.innerWidth / window.innerHeight, 1, 500 )
            scene = THREE.Scene.new()
            cameraRange = 3

            camera.aspect = window.innerWidth / window.innerHeight
            camera.updateProjectionMatrix()
            renderer.setSize( window.innerWidth, window.innerHeight )

            setcolor = "#000000"

            scene.background = THREE.Color.new(setcolor)
            scene.fog = THREE.Fog.new(setcolor, 2.5, 3.5);

            sceneGroup = THREE.Object3D.new();
            particularGroup = THREE.Object3D.new();

            def mathRandom(num = 1):
              setNumber = - Math.random() * num + Math.random() * num
              return setNumber

            particularGroup =  THREE.Object3D.new();
            modularGroup =  THREE.Object3D.new();

            perms = {"flatShading":True, "color":"#111111", "transparent":False, "opacity":1, "wireframe":False}
            perms = Object.fromEntries(to_js(perms))

            particle_perms = {"color":"#FFFFFF", "side":THREE.DoubleSide}
            particle_perms = Object.fromEntries(to_js(particle_perms))

            def create_cubes(mathRandom, modularGroup):
              i = 0
              while i < 30:
                geometry = THREE.IcosahedronGeometry.new();
                material = THREE.MeshStandardMaterial.new(perms);
                cube = THREE.Mesh.new(geometry, material);
                cube.speedRotation = Math.random() * 0.1;
                cube.positionX = mathRandom();
                cube.positionY = mathRandom();
                cube.positionZ = mathRandom();
                cube.castShadow = True;
                cube.receiveShadow = True;
                newScaleValue = mathRandom(0.3);
                cube.scale.set(newScaleValue,newScaleValue,newScaleValue);
                cube.rotation.x = mathRandom(180 * Math.PI / 180);
                cube.rotation.y = mathRandom(180 * Math.PI / 180);
                cube.rotation.z = mathRandom(180 * Math.PI / 180);
                cube.position.set(cube.positionX, cube.positionY, cube.positionZ);
                modularGroup.add(cube);
                i += 1

            create_cubes(mathRandom, modularGroup)


            def generateParticle(mathRandom, particularGroup, num, amp = 2):
              gmaterial = THREE.MeshPhysicalMaterial.new(particle_perms);
              gparticular = THREE.CircleGeometry.new(0.2,5);
              i = 0
              while i < num:
                pscale = 0.001+Math.abs(mathRandom(0.03));
                particular = THREE.Mesh.new(gparticular, gmaterial);
                particular.position.set(mathRandom(amp),mathRandom(amp),mathRandom(amp));
                particular.rotation.set(mathRandom(),mathRandom(),mathRandom());
                particular.scale.set(pscale,pscale,pscale);
                particular.speedValue = mathRandom(1);
                particularGroup.add(particular);
                i += 1

            generateParticle(mathRandom, particularGroup, 200, 2)

            sceneGroup.add(particularGroup);
            scene.add(modularGroup);
            scene.add(sceneGroup);

            camera.position.set(0, 0, cameraRange);
            cameraValue = False;

            ambientLight = THREE.AmbientLight.new(0xFFFFFF, 0.1);

            light = THREE.SpotLight.new(0xFFFFFF, 3);
            light.position.set(5, 5, 2);
            light.castShadow = True;
            light.shadow.mapSize.width = 10000;
            light.shadow.mapSize.height = light.shadow.mapSize.width;
            light.penumbra = 0.5;

            lightBack = THREE.PointLight.new(0x0FFFFF, 1);
            lightBack.position.set(0, -3, -1);

            scene.add(sceneGroup);
            scene.add(light);
            scene.add(lightBack);

            rectSize = 2
            intensity = 14
            rectLight = THREE.RectAreaLight.new( 0x0FFFFF, intensity,  rectSize, rectSize )
            rectLight.position.set( 0, 0, 1 )
            rectLight.lookAt( 0, 0, 0 )
            scene.add( rectLight )

            raycaster = THREE.Raycaster.new();
            uSpeed = 0.1

            time = 0.0003;
            camera.lookAt(scene.position)

            async def main():
              while True:
                time = performance.now() * 0.0003;
                i = 0
                while i < particularGroup.children.length:
                  newObject = particularGroup.children[i];
                  newObject.rotation.x += newObject.speedValue/10;
                  newObject.rotation.y += newObject.speedValue/10;
                  newObject.rotation.z += newObject.speedValue/10;
                  i += 1

                i = 0
                while i < modularGroup.children.length:
                  newCubes = modularGroup.children[i];
                  newCubes.rotation.x += 0.008;
                  newCubes.rotation.y += 0.005;
                  newCubes.rotation.z += 0.003;

                  newCubes.position.x = Math.sin(time * newCubes.positionZ) * newCubes.positionY;
                  newCubes.position.y = Math.cos(time * newCubes.positionX) * newCubes.positionZ;
                  newCubes.position.z = Math.sin(time * newCubes.positionY) * newCubes.positionX;
                  i += 1

                particularGroup.rotation.y += 0.005;

                modularGroup.rotation.y -= ((mouse.x * 4) + modularGroup.rotation.y) * uSpeed;
                modularGroup.rotation.x -= ((-mouse.y * 4) + modularGroup.rotation.x) * uSpeed;

                renderer.render( scene, camera )
                await asyncio.sleep(0.02)

            asyncio.ensure_future(main())
        </py-script>
</html>

Key Components of the webgl.html Python Script:

  1. Scene and Renderer Setup: Utilizes THREE.js for setting up the WebGL renderer and creating the 3D scene.
  2. Creating 3D Objects: Constructs icosahedron geometries (cubes) with random positions, rotations, and scales.
  3. Animating Particles: Generates a group of particles with dynamic behavior, adding life to the scene.
  4. Interactive Camera Controls: Adjusts the camera’s view based on mouse movements for interactive user experience.
  5. Dynamic Lighting and Shadows: Implements lighting and shadow effects to enhance visual depth.
  6. Continuous Rendering Loop: Uses an asynchronous loop for updating object positions and rendering the scene.

Enhancing Your Web Applications with 3D Graphics

The integration of PyScript and WebGL opens up new possibilities for web application development. With Python’s straightforward syntax and the graphical capabilities of WebGL, developers can craft interactive and visually striking web applications. This combination is particularly beneficial for educational tools, data visualization, and interactive storytelling.


The current PyScript API

Three things about the markup above have changed, and all three will break a copied tutorial.

1. The loader and the tag

PyScript moved to date-based releases (2026.7.3 at the time of writing) and to a standard <script> tag with a type attribute. The custom <py-script> element is gone, pyscript.js was replaced by core.js loaded as a module, and — importantly — the latest/ path no longer resolves at all. Pin a release:

<!-- Retired: this path 404s -->
<script defer src="https://pyscript.net/latest/pyscript.js"></script>
<link rel="stylesheet" href="https://pyscript.net/latest/pyscript.css" />

<!-- Current -->
<link rel="stylesheet" href="https://pyscript.net/releases/2026.7.3/core.css" />
<script type="module" src="https://pyscript.net/releases/2026.7.3/core.js"></script>
<!-- Old -->
<py-script>
  print("hello")
</py-script>

<!-- Current -->
<script type="py">
  from pyscript import display
  display("hello")
</script>

Pinning matters more than it used to. latest/ disappearing is exactly the failure mode that silently broke this demo for a couple of years.

2. Two interpreters, not one

PyScript now ships Pyodide (full CPython compiled to WebAssembly — everything works, but you are downloading several megabytes of runtime) and MicroPython (type="mpy", a fraction of the size and dramatically faster to start, at the cost of the standard library and any package needing CPython internals).

<script type="mpy">
  from pyscript import display
  display("MicroPython, and it started almost instantly")
</script>

For a graphics demo like this one, where the Python code is only orchestrating calls into a JavaScript library and never touches NumPy, MicroPython is the better choice: the entire point is the render loop, and the Pyodide startup cost dominates the first-load experience. Reach for Pyodide when you actually need the scientific stack in the browser.

Configuration moved to a JSON config referenced from the script tag, rather than a <py-config> element:

<script type="py" src="./main.py" config="./pyscript.json"></script>

Event handling is also nicer than the create_proxy dance the old API required:

from pyscript import when, display

@when("click", "#render-button")
def handle_click(event):
    display("clicked")

You still need create_proxy when handing a Python callback to a JavaScript API that stores it (as the mousemove listener above does) — that part of the original code is unchanged and still correct.

3. three.js no longer has a global build

The listing above loads three.min.js from a CDN and then reaches for THREE as a global. That build was removed from three.js at r161; the current release is 184 (April 2026). Modern three.js is ES modules only, and browsers need an import map to resolve the bare three specifier:

<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/"
  }
}
</script>

<script type="module">
  import * as THREE from 'three';
  import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
  globalThis.THREE = THREE;      // so PyScript's `from js import THREE` still works
</script>

<script type="py">
  from js import THREE
  renderer = THREE.WebGLRenderer.new({"antialias": True})
</script>

That globalThis.THREE = THREE line is the bridge: PyScript’s from js import X looks up X on the global object, and ES modules deliberately do not put anything there. Assign it yourself and the Python from the listing above works unchanged.

The alternative is to load a pre-r161 UMD build from a CDN — which is what the embedded demo does, since it avoids an import map inside a popup document. It works, but it pins you to a frozen release, and I would not start a new project that way.

Is PyScript worth it in 2026?

Honest assessment, having kept this demo alive for a few years: PyScript is excellent when Python is genuinely the point — teaching material where readers edit real Python in the page, a scientific tool that needs NumPy or pandas client-side, or a way to put an existing Python model in front of people without standing up a backend. The MicroPython runtime made the startup cost defensible for smaller cases too.

It is not the right tool for the demo below. Driving three.js through from js import THREE means every call crosses the Python/JavaScript boundary, you get JavaScript’s semantics with Python’s syntax, and you carry a WebAssembly runtime to do it. The 3D background on this site is written in plain JavaScript for exactly that reason. The demo below is a fun proof that the bridge works — not a recommendation to build your renderer this way.

With that said, the webgl.html example is live below, running on PyScript 2026.7.3. Open it and read the source:

WebGL Popup