Edit on GitHub

starplast.sprite

GPU sphere-impostor materials for the genes in the OpenGL scatter.

Why a fragment shader

pyqtgraph draws a scatter as point sprites: one square of texture per gene, always facing the viewer, with a vertex shader that does nothing but set the size and pass the colour through. There is no fragment shader. The texture it ships is pData[:] = 255 with an alpha disc cut out of it -- every pixel inside a ball carries the identical colour, which is a flat disc, and no amount of per-point shading can make a flat disc look spherical. That is the whole of "the balls still look like 2D matt balls": the per-point lighting was working and had nowhere to show up, because a ball is one colour and a sphere is a gradient.

Glossy and metallic modes replace that path with a compatibility-profile GLSL program. Each fragment uses gl_PointCoord to reconstruct the visible hemisphere's normal, evaluates a GGX microfacet response, reflects a soft studio environment, and writes the sphere surface to gl_FragDepth. Unlike a pre-lit texture, the highlight is continuous, follows world-space lights, and changes with view direction. Metallic reflection is tinted by the gene's data color; glossy reflection remains a dielectric white, so the two materials differ for a physical reason rather than by brightness alone.

Ray mode uploads the cached occupancy grid as a 3D float texture. The vertex shader marches a fixed 24-sample shadow ray from each gene to each active light and passes one stable visibility value to all fragments of that sphere. This is GPU-accelerated volumetric ray tracing, not hardware triangle ray tracing: a gene cloud has density but no triangle surfaces, BVH, reflection rays, or refraction.

The 64×64 CPU textures remain solely as a safe fallback for old drivers and as the flat-disc path.

SPRITES = {'flat': None, 'velvet 3D': {'ambient': 0.24, 'diffuse': 0.78, 'specular': 0.22, 'shininess': 5.0, 'rim': 0.92}, 'glossy 3D': {'ambient': 0.16, 'diffuse': 0.72, 'specular': 1.2, 'shininess': 64.0, 'rim': 0.18}, 'glass 3D': {'ambient': 0.1, 'diffuse': 0.34, 'specular': 1.9, 'shininess': 78.0, 'rim': 1.15}, 'metallic 3D': {'ambient': 0.1, 'diffuse': 0.5, 'specular': 1.65, 'shininess': 26.0, 'rim': 0.75}}
LEGACY_POINT_MODES = {'2D': 'flat', 'matt': 'flat', 'satin': 'glossy 3D', 'glossy': 'glossy 3D', 'metallic': 'metallic 3D', 'pearl 3D': 'glossy 3D', 'brushed metal 3D': 'metallic 3D', 'silver 3D': 'metallic 3D'}
WIDTH = 64
DEFAULT_LIGHT = (-0.4, 0.6, 0.7)
MAX_LIGHTS = 8
RAY_STEPS = 24
MATERIAL_IDS = {'glossy 3D': 1, 'metallic 3D': 2, 'glass 3D': 3, 'velvet 3D': 4}
def ensure_gl_format(minimum=(2, 1)) -> None:

Ask for a surface format pyqtgraph 0.14 will accept, before any GL widget exists.

0.14 refuses to initialise a GLViewWidget when QSurfaceFormat.version() is below 2.1 -- and Qt's DEFAULT format reports 2.0 whatever the driver can do, so on an untouched application the check fires on hardware that exceeds the requirement many times over. The error message prints the driver string (4.6.0 NVIDIA, 4.5 Mesa), which reads as though the card were at fault; the number being tested is the one the application asked for and never set.

Called at import of the module that builds the window, because a default format applied after the first context exists is applied to nothing. Idempotent, and it never LOWERS a version somebody else has already requested.

def disc_alpha(w: int = 64) -> numpy.ndarray:

The round edge, antialiased over the last texel. pyqtgraph's own formula.

Matched deliberately: a ball with a different edge to the halos and centroids drawn beside it reads as a different KIND of thing rather than the same thing lit.

def texture(finish: str, light=(-0.4, 0.6, 0.7), w: int = 64) -> numpy.ndarray:

One ball, lit from light, as (w, w, 4) uint8 ready for GL.

light is a direction in the screen's frame: x right, y up, z toward the viewer. The surface normal is read off the disc -- a texel at (u, v) from the centre of a unit sphere has normal (u, v, sqrt(1 - u^2 - v^2)), which is the whole trick and costs one square root per texel.

def upload(texture_id, data) -> None:

Hand a texture to the card. Only ever called with a current context -- see ShadedScatter.

class ShadedScatter(pyqtgraph.opengl.items.GLScatterPlotItem.GLScatterPlotItem):

A scatter whose glossy modes are GPU-shaded sphere impostors.

The old CPU sphere texture remains the safe fallback. With a working compatibility-profile shader, every fragment reconstructs a sphere normal, computes a GGX glossy/metallic response, and writes the sphere surface's depth. Ray mode samples a 3D density texture in the vertex shader, so one stable shadow value is computed per gene on the GPU rather than on every CPU timer tick.

ShadedScatter(**kwds)
def set_sprite(self, data) -> None:

Set the CPU texture used by flat mode and by the safe shader fallback.

def set_scene(self, mode: str, lights, mood, grid=None, absorb: float = 0.48) -> bool:

Send material, lights and an optional density volume to the next GPU paint.

Returns whether ray transport will run on the GPU. Callers use the false result to retain the existing CPU shadow fallback on old or failed OpenGL contexts.

def gpu_material_enabled(self, mode: str) -> bool:

Whether mode is handled by the per-fragment sphere shader on the next paint.

def initializeGL(self):

Let pyqtgraph initialise its renderer and remember the 0.13 fallback shader.

def getShaderProgram(self):

Return and prepare the VBO material program used by pyqtgraph 0.14.

Its base paint method owns the VBOs and the model/view matrices, so replacing this one seam preserves its renderer while supplying Starplast's per-fragment sphere material.

def paint(self):

Paint flat points normally or glossy/metallic points through the PBR shader.

def to_screen(direction, basis) -> tuple:

A world direction as the screen sees it: x right, y up, z toward the viewer.

The sprite is drawn in the plane of the screen, so a light in world coordinates means nothing to it until it is expressed this way -- and doing it every frame is what makes the highlight on the balls travel with the lights rather than sitting where it was when they were built.