CS optics foundations — version 1, WebGPU engine version 4 (2026-10-24) Availability Use https://cinematicsciences.com/?gpu=1. Confirm getGPUCapabilities(): version>=4, backend WebGPU, runtimeWGSL, imageGrids and computeTextures true. Ordinary WebGL is unchanged. Optics live in cs-optics.js through the existing CS GPU/SendMessage bridge. Do not write C# in activity code. Units are consistent world units (use metres), directions are vectors, angles implicit in normalized directions. Build the activity using these generic tools; do not copy a canned lens game. Save all surface descriptions, image URL, setup and UI callbacks in editor code. clearRun clears ray/image GPU collections. No new camera; use MainCamera via CS. New functions are in getCapabilities and the script validator catalog. Surface description shape: "plane", "sphere" or "lens" center: {x,y,z} or [x,y,z], default origin normal: {x,y,z} or [x,y,z], default +Z; normalized automatically radius: positive, default1. Disk aperture for plane/lens; sphere radius for sphere. material: "mirror" (default), "glass" or "absorber". Lens ignores material. focalLength: required nonzero signed length for lens; positive converges. nMinus/nPlus: refractive indices on negative/positive normal sides, each1..5. Plane defaults1/1.5. Sphere normal is outward; defaults inside1.5/outside1. reflectivity: mirror power fraction0..1, default1. capCos: sphere only, default-1 (whole sphere). Keep intersections with outward normal dot supplied normal >=capCos, e.g.0 retains a hemisphere. Useful for individual curved glass surfaces; BOTH lens boundaries must be defined. Up to32 surfaces. Surfaces are mathematical geometry, not visible meshes or CS physics colliders. Draw frames/lens/object/supports separately with normal CS. There is no automatic extraction of optics from a Unity model or its material. traceOpticalRay(origin,direction,surfaces,options) Inspect ONE ray on CPU; synchronous, returns segments and truncated flag. options: segments=8 (1..16), distance=20 (maximum distance PER segment), branch="transmit" or "reflect". Tiny offset1e-5 avoids self-hits. Resolve details larger than this tolerance; use ordinary laboratory-scale coordinates. Each segment: start,end,surface (-1=miss),event,power,outgoing,transmittedPower, reflectance,distance (cumulative path length; excludes tiny numerical offsets). Event0 escape,1 reflection,2 refraction,3 ideal lens,4 absorption,5 total internal reflection. transmittedPower is outgoing chosen-branch power even for mirrors. Glass uses exact Snell + unpolarized Fresnel. Default follows transmitted branch, reflecting at total internal reflection. The other branch is NOT spawned. Set branch="reflect" for reflected paths. This is power throughput, not radiance. No wavelength dispersion, polarization, scattering, diffraction or interference. var lens = {shape:"lens", center:[0,2,0], radius:0.3, focalLength:2}; var ray = traceOpticalRay([0.2,2,-6], [-0.1,0,6], [lens]); measureThinLens(focalLength,objectDistance) Synchronous paraxial prediction for a real object (objectDistance>0). Returns imageDistance, magnification, real, atInfinity. 1/f=1/u+1/v, m=-v/u. At u=f, imageDistance/magnification=null and atInfinity=true. Negative v denotes virtual image: ray extensions intersect, but no real screen captures that image. var prediction = measureThinLens(2,6); imageDistance=3, magnification=-0.5: real, inverted, half size. var prediction = measureThinLens(2,1); imageDistance=-2, magnification=2: virtual, upright, twice size. Lens model Ideal zero-thickness paraxial lens: outgoing transverse slope equals incident slope minus transverse offset/f. It is reversible from either side. Valid as a small-angle teaching approximation; does not simulate real lens aberrations. Use explicit spherical glass boundaries for geometric aberration ray studies. A visible lens mesh alone cannot refract the ordinary Unity game view. await drawOpticalRays(name,rays,surfaces,options) Compute and display ray segments on GPU in the existing MainCamera. Each ray: {origin:[x,y,z],direction:[x,y,z],color:[r,g,b]}. 1..128 rays, options segments=8,distance=20,branch="transmit",width=.01,extent=1000. Color is display only. Each segment's alpha shows remaining power. Creates a line collection if absent; updates one with same ray/segment count. Remove with removeGPUInstances(name) before changing count. No per-frame CPU ray tracing; compile+one GPU dispatch on demand when settings change. Use UI change/Enter/release events; wait for completion before another update. No direct dragging mechanism is supplied; UI chooses positions and invokes update. await drawOpticalRays("paths", [ {origin:[0.2,2,-6],direction:[-0.2,0,6]}, {origin:[0.2,2,-6],direction:[0.0,0,6]} ], [lens], {segments:3,distance:10}); GPU records: segment index=rayIndex*segments+segmentIndex. measureGPUInstance: position=start, velocity=ENDPOINT (not velocity), color alpha=power; properties=[cumulative distance,incoming power,surfaceIndex,event]. Unused tail records invisible; bounded depth truncates a path. Use traceOpticalRay for a truncated flag and exact CPU diagnostic details, not a whole CPU rendering loop. await renderOpticalImage(name,surfaces,source,screen,options) A GPU projection image of a flat source picture through ONE ideal lens. source={url,center:[x,y,z],width,height}; dimensions in world units. screen={center:[x,y,z],width}; square XY screen, always face-on normalZ. Lens normal must be +/-Z, source and screen are parallel XY planes on opposite sides. This first image tool does not render arbitrary 3D Unity/skinned models, thick/multiple lenses, mirrors, virtual-image views, or the camera's surroundings. A penguin picture is a valid object-plane stand-in for the later candle activity. Use a durable PNG/JPEG/WebP URL (HTTPS or same-origin), CORS permitted, <=4MiB file, <=2048x2048. Alpha is composited on black; sRGB decoded to linear light. No data URLs, local paths, screenshots of the whole page, or temporary pod paths. options: resolution=128 (16..256, square), samples=16 (1..64), distance=20, extent=1000. Optional apertureSurface=0; the single lens must be at index0. For each screen pixel, GPU traces a deterministic disk of aperture samples backwards through the lens to the source plane, then averages sampled colors. This gives inversion, magnification, focus and defocus blur. Brightness is normalized by sample count, NOT calibrated irradiance; stopping down reduces blur, but brightness does not fall physically with aperture area in this preview. No diffraction blur, so small apertures do not model the diffraction limit. Finite sampling can produce grain in defocus; increase samples for comparison. Texture is cached per collection URL and reused on moves. If file content changes, use a versioned URL. Different URL requires fresh fetch. Failed compile/fetch keeps the previous working image/program. Update does not create another camera. Same resolution updates the collection; remove it before changing resolution. var objectPicture = { url:"https://your-host/owned-source.png", center:[0,2,-6], width:2, height:2 }; var screen = {center:[0,2,3],width:2}; await renderOpticalImage("projection",[lens],objectPicture,screen); Move lens.center[2] and rerun the same function to study focus on a fixed screen. Move screen.center[2] to refocus. Calculate u=abs(sourceZ-lensZ), v from measureThinLens; place screen lensZ+v for an object on the -Z side. At u<=f for a converging lens there is no real focused screen image; explain virtual/infinite image using rays and prediction. Do not pretend a virtual image can project. All movement/settings belong in saved activity code and modern responsive UI. Generic infrastructure (not required for a simple lens activity) await createGPUImage(name,width,height,options) RGBA pixel grid displayed on an XY plane, color from particles[i].color. Same 64-byte record layout, count/memory limits as GPU fields. options spacing=.01, origin first pixel CENTER, extent=1000. Initial white; compile before showing. await compileGPUTexture(name,source,url) Same runtime WGSL contract as compileGPU plus sourceImage:texture_2d at binding2. textureLoad reads linear RGB; normalized images can use bilinear loads. Cannot combine this call with snapshot passes yet. Source<=60000characters. Texture <=16MiB decoded, included in512MiB compute budget. getGPUState reports shape and textureBytes. Switching to compileGPU/compileGPUPasses frees texture. remove/clear free textures. No provider/LLM sees the image unless separately sent. Tests and sources Native Unity GPU tests and browser verification have separate results; a passed CPU reference is not proof that browser WGSL ran. Verification uses known Snell, Fresnel/TIR and thin-lens solutions, inverted color target, defocus/refocus and failure rollback. These are fundamentals, not a calibrated optical design suite. https://openstax.org/books/university-physics-volume-3/pages/2-4-thin-lenses https://www.pbr-book.org/4ed/Reflection_Models/Specular_Reflection_and_Transmission Looking THROUGH a sphere or lens (observer view) await viewOpticalImage(name,surfaces,source,view,options) Trace each display pixel from an observer's eye through the optical geometry until it reaches the flat source picture. This is distinct from projecting an image onto a physical screen. Supports the same plane/sphere/lens surfaces, including entry AND exit refraction at a glass sphere. Outside the sphere rays see the source directly; inside they follow the refracted paths. Fresnel losses are applied to the selected branch; the reflected environment is not synthesized. source={url,center,width,height}, same texture limits as renderOpticalImage. view={eye:[x,y,z],center:[x,y,z],width}; center is a square XY display plane between eye and source, separate from the mathematical lens. Use MainCamera to look at this image plane; eye is the mathematical observer for the ray tracer, not a second Unity camera. Do not put a solid sphere mesh in front of the image plane: that would hide it. A separate side-view outline can show lens position. options resolution=128 (16..256),segments=8 (1..16),distance=20, branch="transmit",background=[.025,.035,.055],extent=1000. Return metadata only; use measureGPUInstance for sampled colors and properties: [interface count,remaining branch power,source reached (1/0),0]. Image refresh is explicit after moving lens/eye/source, not a live 3D scene capture. Finite 1e-5 offsets and float32 arithmetic limit precision near tangencies. var glass = {shape:"sphere",center:[0,2,0],radius:1, material:"glass",nMinus:1.5,nPlus:1}; var picture = {url:"https://your-host/owned-source.png", center:[0,2,4],width:8,height:8}; var observer = {eye:[0,2,-4],center:[0,2,-2],width:2}; await viewOpticalImage("throughGlass",[glass],picture,observer); This traces a flat picture through actual spherical boundaries. It is NOT yet ray tracing the geometry of a live Unity penguin. Use a penguin picture for the first stand-in; mesh capture/intersection is a later extension. All media here are nonabsorbing unless the surface material is absorber; the source is treated as a luminous picture, so ordinary Unity lights do not control this ray image.