{"id":"sebasvcx/viewport-stencil","name":"viewport-stencil","scope":"sebasvcx","platform":"roblox","description":"Render models inside surfaces with ViewportFrames: ground cracks, holes, portals, without cutting anything.","version":"1.0.1","latest":"1.0.1","versions":["1.0.0","1.0.1"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"048b20e98a2f420e4b8d3edc12640e36c270cb517ea93d5d7d36778704448ff4","likes":0,"downloads":0,"install":"forest install sebasvcx/viewport-stencil","url":"https://forest.dev/p/roblox/sebasvcx/viewport-stencil","files":"https://api.forest.dev/ai/package/roblox/sebasvcx/viewport-stencil/files","readme":"# ViewportStencil\n\nRender models *inside* surfaces in Roblox: ground cracks, holes, craters, portals, windows into walls. Nothing is cut or\ndestroyed: a ViewportFrame on the surface shows the model as if you were looking through the surface into it, from any\ncamera angle.\n\n![A crack in a wall and another in the floor, both rendered with ViewportStencil](docs/images/demo.png)\n\n## How it works\n\nViewportStencil combines two existing techniques:\n\n- **[rbx-viewport-window](https://github.com/EgoMoose/rbx-viewport-window)** by EgoMoose: an off-axis projection that\n  makes a ViewportFrame on a SurfaceGui line up with the world behind it, so it looks like a window instead of a flat\n  image.\n- **[ViewportFrame masking](https://devforum.roblox.com/t/viewportframe-masking/2964839)**: faces whose vertex alpha\n  has been erased render invisible inside a ViewportFrame, but still hide whatever is behind them. The model carries its\n  own mask: a flat plane around the opening with erased alpha. Through the mask you see the real ground, and the rest of\n  the model only shows through the opening.\n\nThe mask is part of the mesh, so each model defines its own shape. See [Making a model](#making-a-model).\n\n## Installation\n\n**Wally**\n\n```toml\n[dependencies]\nViewportStencil = \"sebasvcx/viewport-stencil@1.0.1\"\n```\n\n**Manually**: download `ViewportStencil.rbxm` from the Releases page and put it in `ReplicatedStorage`.\n\n**Just want to try it?** Download `ViewportStencil-Demo.rbxl` from the Releases page, open it in Studio and press Play.\nSee [Example](#example).\n\n## Example\n\nThe [Releases page](../../releases) has a demo place, `ViewportStencil-Demo.rbxl`, ready to play: click anywhere\n(floor or wall) to spawn a crack that disappears after 10 seconds.\n\nWhat's in the demo:\n\n- `ReplicatedStorage.ViewportStencil`: the module.\n- `ReplicatedStorage.Assets.Crack.CrackTest`: the crack model from [`example/crack.blend`](example/crack.blend), set up as\n  described in [Making a model](#making-a-model).\n- `ReplicatedStorage.Assets.Crack.VFX`: particles and a purple light spawned with each crack. The light tints nearby\n  stencils; see [Lighting](#lighting).\n- `StarterPlayerScripts.Example`: the script that spawns the cracks, [`example/Example.client.lua`](example/Example.client.lua).\n\nIn this repo, `example/` has the demo script and the Blender file for the crack.\n\n## Usage\n\nViewportStencil only runs on the client.\n\n```lua\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal ViewportStencil = require(ReplicatedStorage.ViewportStencil)\n\nlocal crack = ReplicatedStorage.Crack -- a Model\n\n-- A CFrame on whatever the mouse is pointing at, randomly rotated around the surface normal\nlocal cframe = ViewportStencil.Utils.fromMouse(nil, nil, math.random() * 2 * math.pi)\nif cframe then\n\tViewportStencil.new(crack:Clone(), cframe, { lifetime = 10 })\nend\n```\n\nThe stencil takes ownership of the model (it's destroyed along with the stencil), so pass a clone.\n\nStencils work on any surface. The CFrame is a point on the surface, with its UpVector along the surface normal. The\n`Utils` functions build it from a raycast, so walls and ceilings work the same as floors.\n\n## API\n\n### `ViewportStencil.new(model: Model, cframe: CFrame, options: StencilOptions?): Stencil`\n\nCreates a stencil that renders `model` inside the surface at `cframe`.\n\n| Option | Default | |\n| --- | --- | --- |\n| `size: Vector2` | 90% of the model's footprint | Surface size in studs, along the CFrame's X and Z axes. The model is clipped outside it. |\n| `lifetime: number` | never | Seconds until the stencil destroys itself. |\n| `destroyModel: boolean` | `true` | When `false`, the model is unparented instead of destroyed. |\n| `transparency: number` | `0` | Transparency of the whole stencil. |\n| `maxDistance: number` | `1000` | Hidden beyond this distance from the camera. |\n| `brightness: number` | `1` | `SurfaceGui.Brightness` |\n| `lightInfluence: number` | `1` | `SurfaceGui.LightInfluence` |\n| `ambient: Color3` | white | `ViewportFrame.Ambient` |\n| `lightColor: Color3` | `(140, 140, 140)` | `ViewportFrame.LightColor` |\n| `lightDirection: Vector3` | `(-1, -1, -1)` | `ViewportFrame.LightDirection` |\n\n### `Stencil`\n\n| | |\n| --- | --- |\n| `stencil.model` | The model being rendered. Don't reparent it. |\n| `stencil:setCFrame(cframe)` / `getCFrame()` | Moves the stencil. |\n| `stencil:setSize(size: Vector2)` / `getSize()` | Resizes the surface. |\n| `stencil:setTransparency(t)` / `getTransparency()` | Useful to fade it out before destroying it. |\n| `stencil:destroy()` | Also `:Destroy()`, so it works with Maid, Janitor, Trove... Safe to call more than once. |\n| `stencil:isDestroyed()` | |\n\n### `ViewportStencil`\n\n| | |\n| --- | --- |\n| `getActive(): { Stencil }` | Every stencil that hasn't been destroyed. |\n| `destroyAll()` | |\n\n### `ViewportStencil.Utils`\n\nAll of these return a CFrame ready for `new`, or `nil` if nothing was hit. `angle` (radians) spins the stencil around\nthe surface normal. `params` defaults to ignoring the local player's character.\n\n| | |\n| --- | --- |\n| `fromMouse(params?, maxDistance?, angle?)` | Whatever is under the mouse. |\n| `fromScreenPoint(x, y, params?, maxDistance?, angle?)` | Whatever is under a point in viewport coordinates. |\n| `belowCharacter(character?, maxDistance?, angle?)` | The ground under a character (the local player's by default). |\n| `raycast(origin, direction, params?, angle?)` | Whatever the ray hits. |\n| `fromRaycastResult(result, angle?)` | |\n| `fromNormal(position, normal, angle?)` | |\n\n## Making a model\n\nA model is a mesh with two parts: the **mask**, a flat plane on top with a hole in it, and the **visible part**, what\nyou see through the hole (the walls and bottom of a crack, for example). This walks through a ground crack in Blender;\nthe finished file is in [`example/crack.blend`](example/crack.blend). The\n[DevForum post](https://devforum.roblox.com/t/viewportframe-masking/2964839) explains the masking and the erased-alpha\nvertex paint in more detail.\n\n### In Blender\n\n**1.** Model the mesh: a flat plane with the opening cut out, and the geometry that goes below it.\n\n![Initial mesh](docs/images/blender/01-initial-mesh.png)\n\n**2.** Separate it into two parts: the mask (the plane) and the visible part (everything below). Vertex colors are\nstored per vertex, so if they shared vertices, erasing the mask's alpha would also fade the edges of the visible part.\n\n![Separated mesh](docs/images/blender/02-separate-mesh.png)\n\nTo see the erased alpha while painting, give the mesh a material with a **Color Attribute** node whose **Alpha** output\ngoes into the **Base Color** of the Principled BSDF, and switch the viewport shading to **Material Preview**.\n\n![Color Attribute node](docs/images/blender/03-color-attribute-node.png)\n\n![Material Preview](docs/images/blender/04-material-preview.png)\n\n**3.** Switch to **Vertex Paint** mode.\n\n![Vertex Paint mode](docs/images/blender/05-vertex-paint-mode.png)\n\n**4.** Set the brush's blending mode to **Erase Alpha**.\n\n![Erase Alpha](docs/images/blender/06-erase-alpha.png)\n\n**5.** Paint over the whole mask plane. Any part you miss will be visible in game.\n\n![Painting the mask](docs/images/blender/07-paint-progress.png)\n\n**6.** When you're done, the whole mask should look black with the material from step 2, and the visible part should\nbe untouched.\n\n![Finished mask](docs/images/blender/08-paint-finished.png)\n\n**7.** Export both objects together as one FBX (no need to join them in Blender), then import the FBX into Studio as a\nsingle mesh, so both parts end up in one MeshPart.\n\n### In Studio\n\nPut the imported MeshPart in a `Model` and set it as the model's `PrimaryPart`:\n\n```\nCrack (Model, PrimaryPart = Crack)\n└── Crack (MeshPart)\n```\n\n- The **top of the `PrimaryPart`** is placed flush with the surface, so the mask plane must be the highest point of the\n  mesh. Without a `PrimaryPart`, the top of the model's bounding box is used.\n- The model's **up** is its `PrimaryPart`'s UpVector (world up without a `PrimaryPart`), aligned with the surface normal.\n- Keep the opening centered on the mesh: the surface is centered on the stencil's CFrame.\n- By default the surface is 90% of the model's footprint. Leaving the mask's outer edges out of the surface hides a\n  thin line of light that otherwise shows along them. If you pass your own `size`, keep it a bit smaller than the mask.\n- Keep the mask tight around the opening. The viewport's pixels are spread over the whole surface, so a lot of empty\n  mask around a small crack makes it blurrier.\n- Anything else in the model (extra parts, effects) works as long as it stays below the mask.\n\n## Lighting\n\nA stencil is lit in two separate layers, and both change how the model looks:\n\n1. **The ViewportFrame's own lighting.** Objects inside a ViewportFrame don't use `Lighting` or any lights in the\n   world. They only get the ViewportFrame's `Ambient` light and one directional light (`LightColor` and\n   `LightDirection`). This is where the model gets its shading.\n2. **World lighting on the SurfaceGui.** Once the ViewportFrame's image is drawn on the surface, the SurfaceGui is lit\n   by the world like any other surface, scaled by `LightInfluence`. With `lightInfluence = 1`, a colored light near the\n   stencil tints it. With `0`, it ignores world lighting and shows the ViewportFrame's image as is.\n\n`brightness` (`SurfaceGui.Brightness`) multiplies the final result. It's useful for glowing effects.\n\nThe defaults (white `ambient`, `lightInfluence = 1`) light the model evenly and let it pick up the world's lights. That\nsuits glowing, magical cracks, but a plain grey mesh will look flat and bright, and will take the color of any nearby\nlight.\n\nSome starting points:\n\n```lua\n-- Realistic hole: dark inside, lit from above, ignores world lights\nViewportStencil.new(model, cframe, {\n\tambient = Color3.fromRGB(60, 60, 60),\n\tlightColor = Color3.fromRGB(200, 200, 200),\n\tlightDirection = Vector3.new(0, -1, 0),\n\tlightInfluence = 0,\n})\n\n-- Glowing crack: fully lit and brighter than its surroundings\nViewportStencil.new(model, cframe, {\n\tambient = Color3.new(1, 1, 1),\n\tlightInfluence = 0,\n\tbrightness = 2,\n})\n\n-- Blends with the scene: shaded by the viewport, tinted by nearby lights\nViewportStencil.new(model, cframe, {\n\tambient = Color3.fromRGB(120, 120, 120),\n\tlightInfluence = 1,\n})\n```\n\n`lightDirection` is the direction the light travels, in world space: `(0, -1, 0)` shines straight down and lights\nupward-facing surfaces. The default `(-1, -1, -1)` comes diagonally from above.\n\nThe model's own colors, materials and textures still apply inside the ViewportFrame, with some limits: ViewportFrames\ndon't render shadows or post-processing, and Neon and Glass render at the lowest quality, so Neon shows as a flat,\nbright color that doesn't glow or light up anything around it.\n\n## Performance\n\n- Only stencils that are on screen, within `maxDistance` and in front of their surface are rendered. The rest have\n  their SurfaceGui disabled and skip their per-frame update.\n- When the camera doesn't move, nothing is updated.\n- The real cost is the GPU rendering each visible ViewportFrame, so keep the number of stencils on screen reasonable\n  and the meshes simple.\n\n## Limitations\n\n- Client only.\n- ViewportFrames have no anti-aliasing, so edges are slightly jagged.\n- The surface is a rectangle; the mask on the model is what gives it its shape.\n- Overlapping stencils don't merge: the one on top covers the other.\n\n## Development\n\n```sh\nrojo serve dev.project.json\n```\n\n`dev.project.json` syncs the library into `ReplicatedStorage.ViewportStencil` and the example from `example/` into\n`StarterPlayerScripts`. `default.project.json` is just the library, used for Wally and `rojo build`.\n\n## Credits\n\n- [EgoMoose](https://github.com/EgoMoose) for [rbx-viewport-window](https://github.com/EgoMoose/rbx-viewport-window).\n- [ViewportFrame masking](https://devforum.roblox.com/t/viewportframe-masking/2964839) on the DevForum.\n\n## License\n\n[MIT](LICENSE)\n","readmeTruncated":false}