| id | ffi-graphics |
|---|---|
| title | FFI Graphics — A Rotating Teapot |
| slug | /examples/ffi-graphics |
This example uses FScript to generate a Utah teapot and animate it in an OpenGL window. The script supplies vertices, normals, triangle indices, camera, lighting, and material. A .NET host exposes typed functions to upload and draw the geometry.
Start here after imports and exports. You will see how script-created data records and opaque host resources work together through the same FFI bridge.
Run the sample directly with the installed FScript CLI (0.81.5 or later), from the repository root:
fscript samples/ffi/main.fssThe CLI finds samples/ffi/fscript.toml beside main.fss automatically. With CLI 0.81.4 or earlier, pass --config samples/ffi/fscript.toml explicitly.
Execution starts in main.fss, which loads the scene and starts rendering through the graphics provider.
Check out the FScript repository and build the provider library with the .NET 10 SDK before the first run:
dotnet build samples/ffi/FfiSample.fsproj -c ReleaseThis compiles the FFI library used by fscript. Rebuild it when changing provider code; script edits only require running the fscript command again. Version 0.81.3 includes the macOS signing entitlement required to load GLFW.
A copper-colored teapot rotates until you close the window or press Escape. The window can be resized. You need a desktop display and an OpenGL 3.3 core-capable driver. Linux also needs GLFW's system windowing dependencies. macOS uses a forward-compatible core context.
For a machine without a display, run the same scripts and FFI adapters against a recording backend:
fscript samples/ffi/main.fss -- --headlessThe default scene generates 2,592 vertices and 4,096 triangles and renders three recorded frames in headless mode. This exercises script execution and conversions; it does not test OpenGL. For a bounded graphical check, use -- --frames 3 to render three frames, check for teapot-colored pixels in the framebuffer, and exit.
The sample's fscript.toml configures the .NET backend, AOT, and size optimization. With the provider built, compile without extra options:
fscript compile samples/ffi/main.fss
# On macOS ARM64:
samples/ffi/bin/main/dotnet/osx-arm64/aot/main --headlessOther platforms use their own .NET runtime identifier in the path. Windows adds .exe. The compiler prints the output location. From the sample directory, use fscript compile main.fss.
To retain full framework diagnostics and choose a different output directory:
dotnet run --project src/FScript -- compile samples/ffi/main.fss --no-size-optimized --output out/ffi
out/ffi/main --headless
out/ffi/main --frames 3The sample enables size-optimized = true in its manifest to trade culture support and framework diagnostics for a smaller executable. It enables invariant globalization, replaces framework exception messages with resource keys, and disables runtime stack traces and EventSource. Use --no-size-optimized to override that default. AOT without configured size optimization keeps those facilities. Both modes optimize native code for size and embed only the selected platform's GLFW library; the provider DLL remains portable for interpreted execution.
On macOS ARM64 with .NET SDK 10.0.403, the executable shrank from 4,378,616 bytes to 2,181,464 bytes with ordinary AOT, or 1,882,776 bytes with --size-optimized. Both profiles passed headless and real OpenGL checks. Sizes depend on SDK and platform; see Native AOT executable size for the investigation and runtime tradeoffs.
The compiler reports its phases and output path. The result is one executable, main.exe on Windows. Publishing requires the .NET 10 SDK and the platform's native compiler; running the result requires no .NET installation. The provider supplies static CLR companions and typed setup/render delegates. GLFW binaries are embedded in the renderer and extracted only for graphical startup. Desktop graphics and OpenGL drivers are still required for a window.
All sample code lives under samples/ffi.
| File | Purpose |
|---|---|
scene.fss |
Chooses scene data, uploads the generated mesh, and animates it. |
teapot.fss |
Holds the Bézier patch data and generates vertices, normals, and triangle indices. |
Graphics.fs |
Declares the script-visible types and typed FFI functions. |
main.fss |
Starts the window loop and returns a render closure that retains the mesh. |
Renderer/ |
Implements the native window, GPU buffers, shaders, and rendering. |
fscript.toml |
Makes the provider available to CLI and editor analysis. |
Tests/ |
Tests geometry generation, scene calls, conversions, and resource boundaries without a display. |
Here is the complete scene.fss:
import "teapot.fss" as Teapot
// These are script-created instances of types supplied by the graphics module.
let background = { R = 0.055; G = 0.075; B = 0.11 }
let camera =
{ Eye = { X = 6.0; Y = 3.0; Z = 7.0 }
Target = { X = 0.0; Y = 0.0; Z = 0.0 }
FieldOfView = 0.65 }
let light =
{ Position = { X = 4.0; Y = 6.0; Z = 5.0 }
Color = { R = 1.0; G = 0.94; B = 0.85 }
Ambient = 0.22 }
let copper =
{ Color = { R = 0.78; G = 0.30; B = 0.12 }
Shininess = 56.0 }
export let initialize (window: Window) =
// Generate and upload only once. The returned Mesh retains hidden GPU state.
let geometry = Teapot.generate 8
Graphics.uploadMesh window geometry
export let render (window: Window) (mesh: Mesh) (seconds: float) =
Graphics.clear window background
Graphics.setCamera window camera
Graphics.setLight window light
let rotation =
{ X = 0.12 * Math.sin seconds
Y = seconds * 0.45
Z = 0.0 }
Graphics.draw window mesh rotation copperColor, Camera, Light, Material, and Vec3 are types supplied by the host module. They allow script construction, so their values use ordinary record literals. The camera looks from Eye toward Target with Y up; FieldOfView and rotation components are in radians.
Window and Mesh are opaque types. The host supplies the window, and Graphics.uploadMesh creates the mesh. The script can read metadata such as window.Title and mesh.VertexCount, but cannot construct these types, update them, or access their hidden graphics state.
The entry script calls Scene.initialize once and retains the returned mesh in a closure. Graphics.run pumps window events and invokes that closure each frame through ExternalCallContext.Apply, passing elapsed seconds. Only small scene records cross the boundary during animation; geometry is generated and uploaded once.
The executable entry point is main.fss:
import "scene.fss" as Scene
// The script owns startup and retains the mesh in a render closure.
let start (window: Window) =
let mesh = Scene.initialize window
fun seconds -> Scene.render window mesh seconds
let parseFrames text = Int.tryParse text |> Option.defaultValue (0 - 1)
match Env.Arguments with
| [] -> Graphics.run 0 start
| ["--headless"] -> Graphics.runHeadless 3 start
| ["--headless"; count] -> Graphics.runHeadless (parseFrames count) start
| ["--frames"; count] -> Graphics.run (parseFrames count) start
| _ -> Graphics.run (0 - 1) startGraphics.run 0 start runs until the window closes; a positive limit stops after that many frames. Graphics.runHeadless requires a positive limit and uses a recording backend.
The teapot consists of 32 bicubic Bézier patches. Each patch references a four-by-four grid of control points. teapot.fss contains both the control points and the patch connectivity.
For each patch, the script:
- Samples parameters
uandvbetween zero and one. - Evaluates cubic Bézier curves in both directions to find a position.
- Computes surface tangents and their cross product to find a normal.
- Connects neighboring samples into indexed triangles.
For example, cubic curve evaluation is ordinary script arithmetic:
let curve points t =
let (a, b, c, d) = points
let s = 1.0 - t
let first = add (scale (s * s * s) a) (scale (3.0 * s * s * t) b)
let second = add (scale (3.0 * s * t * t) c) (scale (t * t * t) d)
add first secondThe result is a Geometry record containing a name, a list of Vertex records, and an integer list of zero-based triangle indices. Each vertex has a position and a normal. The host receives the finished geometry; it contains no teapot primitive or model-generation code.
The sample supplies Math.toFloat, Math.sqrt, and Math.sin as typed extern functions for parameter conversion, normal normalization, and animation. These functions belong to this provider, rather than the standard library. The dataset's attribution and license are included in THIRD-PARTY-NOTICES.md.
Graphics.fs uses attributes on real F# types. Here are two representative definitions:
[<FScriptHostType("Vec3", ScriptConstructible = true)>]
type Vec3 =
{ [<FScriptVisible>] X: float
[<FScriptVisible>] Y: float
[<FScriptVisible>] Z: float }
[<FScriptHostType("Window")>]
type Window =
{ [<FScriptVisible>] Title: string
Backend: IGraphics }Every constructor field of a script-constructible record must be visible. Opaque types can retain unmarked fields: Window.Backend stays in the host and never becomes a script field.
| Types | Script construction | Role |
|---|---|---|
Vec3, Color |
Allowed | Small numeric values. |
Vertex, Geometry |
Allowed | Script-generated mesh data. |
Camera, Light, Material |
Allowed | Script-controlled scene settings. |
Window, Mesh |
Forbidden | Host-created resources with visible metadata and hidden state. |
HostTypes.describe turns each attributed type into a plain HostTypeDescriptor. For Vec3, the generated data is:
{ Name = "Vec3"
Fields = Map.ofList [ ("X", TFloat); ("Y", TFloat); ("Z", TFloat) ]
HostManaged = false }A Window descriptor instead has HostManaged = true and only a visible Title: string field. Neither descriptor contains a native resource or conversion function. See Host-Provided Types for the descriptor fields and conversion rules.
The upload handler has the concrete F# type Window -> Geometry -> Mesh. It validates geometry, uploads it through window.Backend, and returns a mesh retaining that backend and its resource handle. The complete implementation is in Graphics.fs.
Register it with a registration that already contains the referenced type descriptors:
HostFunctions.create types "Graphics.uploadMesh" uploadThe helper builds an ExternalFunction record:
| Field | Generated value or behavior |
|---|---|
Name |
Graphics.uploadMesh |
Scheme |
Forall([], TFun(TNamed "Window", TFun(TNamed "Geometry", TNamed "Mesh"))) |
Arity |
2 |
Impl |
Validates and decodes script arguments, calls upload, and wraps the returned opaque mesh. |
An ExtensionRegistration groups the nine type descriptors in Types and the ten function descriptors in Functions. The assembly provider returns this registration for editor and CLI discovery. The two window-loop functions use explicit ExternalFunction records because they accept script callbacks; geometry, rendering, and math functions continue to use HostFunctions.create.
The renderer-facing functions are:
| Function | Script type | Purpose |
|---|---|---|
Graphics.uploadMesh |
Window -> Geometry -> Mesh |
Upload geometry after validating vertices, normals, and indices. |
Graphics.clear |
Window -> Color -> unit |
Clear color and depth buffers. |
Graphics.setCamera |
Window -> Camera -> unit |
Set the view and perspective projection. |
Graphics.setLight |
Window -> Light -> unit |
Set point lighting and ambient strength. |
Graphics.draw |
Window -> Mesh -> Vec3 -> Material -> unit |
Draw the mesh with an Euler rotation and material. |
The host rejects invalid graphics inputs and attempts to draw a mesh through another window's backend. The window loop stays on its launching thread, and GPU resources are released before its context is destroyed, including when a script call fails. For the general adapter API, see Register Extern Functions.
Build the sample in Release, then run FScript: Select Configuration in VS Code and choose samples/ffi/fscript.toml:
[runtime]
extern-assemblies = ["bin/Release/net10.0/FScript.Ffi.Sample.dll"]The path is relative to the manifest. In a trusted workspace, the editor loads the provider's descriptors and schemes for completion, hover, and diagnostics. Provider discovery creates no window or GPU resources. If several manifests exist in the workspace, select this one explicitly.
The CLI can load the same environment:
fscript samples/ffi/main.fssThis runs main.fss and opens the window. In VS Code, open main.fss and use FScript: Run Current Script after selecting the manifest. The command saves the file and runs a fresh CLI process with the selected configuration. It requires workspace trust; REPL commands remain separate. See Environment Configuration for selection, trust, and reload behavior.
- Change the material color or shininess in
scene.fss. - Move the camera or light, or change the rotation speed.
- Change
Teapot.generate 8toTeapot.generate 16for a denser mesh. Resolution is clamped to 2–32 segments per patch edge. - Adjust the Bézier control points in
teapot.fssto alter the model itself.
Re-run the sample after editing; it loads scripts at startup. Keep mesh uploads in initialization so the render loop reuses GPU buffers.
To run the sample's automated checks:
dotnet test samples/ffi/Tests/FfiSample.Tests.fsproj -c ReleaseThe provider's only FScript package reference is MagnusOpera.FScript.Extensibility 1.0.0. Open FScript.Extensibility for attributes, descriptors, HostTypes, and HostFunctions. This independently versioned SDK contains no interpreter implementation. The renderer remains a separate project; only the integration test harness references Runtime. Interpreted window callbacks use opaque SDK handles through ExternalCallContext; compiled callbacks use Func<Window, Action<double>>. Both paths share validation and the frame loop. The renderer calls GLFW/OpenGL through unmanaged function pointers so Native AOT does not need reflection or dynamic delegate generation.