Prerequisites
Everything in this guide is embedded below β both code files have copy buttons. You'll paste the shader into a text file for OBS and the C# script directly into Streamer.bot.
- OBS Studio 28+ with the WebSocket server enabled: Tools β WebSocket Server Settings β Enable, port 4455. Note the password if you set one.
- obs-shaderfilter plugin β download from the plugin's GitHub releases page for your OBS version and OS. Restart OBS after installing.
- Streamer.bot connected to both Twitch and OBS (Stream Apps β OBS Studio β add connection on port 4455).
- Your 360 camera feeding OBS in equirectangular mode. The Insta360 X5 in webcam mode outputs 2:1 panoramic at 2880Γ1440.
OBS: Capture the camera
- Add a Video Capture Device source for the camera. Name it something exact and memorable, e.g. Insta360 X5 β you'll need this name character-for-character in the script later.
- In the source properties, uncheck "Use Preset" and manually set the resolution to 2880Γ1440 at 30 FPS. If OBS defaults to 1920Γ1080 or 720p here, your final image quality tanks β this is the #1 quality complaint.
- Confirm the raw feed looks like a stretched-out unwrapped panorama. That's equirectangular β correct. If you see a normal flat view, the camera is in reframe/single-lens mode instead.
Install the reframe shader
- Copy the shader below and save it as mushbox-360-reframe.shader anywhere on your PC (Notepad is fine β just make sure the extension isn't secretly .txt).
- In OBS: right-click the camera source β Filters β + β User-defined shader. Keep the default filter name User-defined shader (or note what you name it β the script needs it exactly).
- In the filter, check Load shader text from file and browse to the file you saved.
- You should now see a flat, normal-looking view with sliders: Yaw, Pitch, Roll, Field of view, Output aspect, plus Projection and Tiny-planet wrap (new in v2.4). Set Output aspect to 1.7778 for 16:9. FOV around 95β115 looks natural. Leave Projection at 0 for the normal view.
- Fix the frame: right-click the source β Transform β Edit Transform β Bounding Box Type Stretch to bounds, size 1920Γ1080 (or your canvas size).
// ============================================================
// Mushbox 360 Reframe Shader (for obs-shaderfilter) -- v2.4
// Converts equirectangular 360 video (e.g. Insta360 X5, 2:1)
// into a flat rectilinear view OR a stereographic "tiny planet"
// view, with adjustable yaw / pitch / roll / FOV / output aspect.
//
// v2.1: fixed the vertical seam visible when facing the rear
// (yaw 180) by manually blending across the texture edge.
// v2.2: fixed inverted pitch -- positive pitch now looks UP.
// v2.3: added BASE_YAW mounting offset so yaw 0 = true front
// regardless of which lens faces forward.
// v2.4: added tiny-planet (stereographic) projection mode.
// Set "projection" to 1 for little-planet. In planet mode the
// view looks straight DOWN at the nadir and wraps the horizon
// (plus some sky) into a ring around the planet. "planet_fov"
// controls how much of the sphere wraps -- BIGGER = smaller
// planet with more sky ring. "yaw" and "roll" spin the planet,
// so !spin still works. Pitch is intentionally ignored in
// planet mode (nadir-locked) to keep the classic look.
//
// IMPORTANT: The uniform names below (yaw, pitch, roll, fov,
// output_aspect, projection, planet_fov) are what the Streamer.bot
// script writes to. If you rename them, rename them in the C#
// script too.
// ============================================================
// Mounting offset in degrees. 180 = camera's "rear" lens faces
// the front of the vehicle. Change to 0 / 90 / -90 as needed.
#define BASE_YAW 180.0
uniform float yaw<
string label = "Yaw (degrees)";
string widget_type = "slider";
float minimum = -360.0;
float maximum = 360.0;
float step = 0.1;
> = 0.0;
uniform float pitch<
string label = "Pitch (degrees)";
string widget_type = "slider";
float minimum = -90.0;
float maximum = 90.0;
float step = 0.1;
> = 0.0;
uniform float roll<
string label = "Roll (degrees)";
string widget_type = "slider";
float minimum = -180.0;
float maximum = 180.0;
float step = 0.1;
> = 0.0;
uniform float fov<
string label = "Field of view (degrees)";
string widget_type = "slider";
float minimum = 30.0;
float maximum = 150.0;
float step = 0.5;
> = 95.0;
uniform float output_aspect<
string label = "Output aspect (width/height, 1.7778 = 16:9)";
string widget_type = "slider";
float minimum = 0.5;
float maximum = 4.0;
float step = 0.0001;
> = 1.7778;
// --- v2.4 additions -----------------------------------------
// Projection mode: 0 = normal rectilinear reframe, 1 = tiny planet.
uniform float projection<
string label = "Projection (0 = normal, 1 = tiny planet)";
string widget_type = "slider";
float minimum = 0.0;
float maximum = 1.0;
float step = 1.0;
> = 0.0;
// Tiny-planet wrap amount (degrees): the effective field of view
// measured from the nadir out to the top/bottom frame edge.
// ~200 = tight planet just past the horizon; ~250 = horizon plus
// a healthy sky ring; ~300 = tiny planet floating in lots of sky.
uniform float planet_fov<
string label = "Tiny-planet wrap (degrees)";
string widget_type = "slider";
float minimum = 120.0;
float maximum = 320.0;
float step = 1.0;
> = 250.0;
#define PI 3.14159265358979
float4 mainImage(VertData v_in) : TARGET
{
// Both projection modes produce a unit direction vector "dir"
// (x right, y up, z forward), which then feeds the shared
// equirect-UV + seam-fix tail below.
float3 dir;
if (projection < 0.5)
{
// ================= Rectilinear (v2.3 path) =================
float fovRad = radians(clamp(fov, 30.0, 150.0));
float halfTan = tan(fovRad * 0.5);
float ndcX = (v_in.uv.x - 0.5) * 2.0;
float ndcY = (0.5 - v_in.uv.y) * 2.0;
float3 ray = normalize(float3(
ndcX * halfTan,
ndcY * halfTan / output_aspect,
1.0
));
// Roll (around forward/z axis)
float cr = cos(radians(roll));
float sr = sin(radians(roll));
ray = float3(cr * ray.x - sr * ray.y,
sr * ray.x + cr * ray.y,
ray.z);
// Pitch (around x axis; negated so positive = up)
float cp = cos(radians(pitch));
float sp = sin(radians(-pitch));
ray = float3(ray.x,
cp * ray.y - sp * ray.z,
sp * ray.y + cp * ray.z);
// Yaw (around vertical/y axis; BASE_YAW mount offset)
float yawTotal = yaw + BASE_YAW;
float cy = cos(radians(yawTotal));
float sy = sin(radians(yawTotal));
ray = float3( cy * ray.x + sy * ray.z,
ray.y,
-sy * ray.x + cy * ray.z);
dir = ray;
}
else
{
// ================= Tiny planet (stereographic) =================
// Centered, aspect-corrected screen coords so the planet is
// round rather than oval on a 16:9 canvas.
float2 sc = float2((v_in.uv.x - 0.5) * 2.0 * output_aspect,
(0.5 - v_in.uv.y) * 2.0);
// Roll spins the flat planet image.
float cr = cos(radians(roll));
float sr = sin(radians(roll));
sc = float2(cr * sc.x - sr * sc.y,
sr * sc.x + cr * sc.y);
float rr = length(sc);
// Yaw (+ mount offset) rotates the planet, so !spin works.
float az = atan2(sc.y, sc.x) + radians(yaw + BASE_YAW);
// Inverse stereographic: the top/bottom frame edge (rr ~= 1
// in height units) maps to planet_fov/2 degrees from the
// nadir. theta is the polar angle measured from straight-down.
float s = tan(radians(clamp(planet_fov, 120.0, 320.0)) * 0.25);
float theta = 2.0 * atan(s * rr);
float st = sin(theta);
dir = float3( st * cos(az),
-cos(theta), // nadir (straight down) at center
st * sin(az));
}
// ================= Shared: direction -> equirect UV =================
float lon = atan2(dir.x, dir.z); // -PI..PI
float lat = asin(clamp(dir.y, -1.0, 1.0)); // -PI/2..PI/2
float u = frac(lon / (2.0 * PI) + 0.5);
float v = 0.5 - lat / PI;
// --- Seam fix: manually blend across the texture edge ---
// Bilinear filtering clamps at the left/right edges, which
// draws a thin line when the view crosses the rear seam.
float w = max(uv_pixel_interval.x, 0.0001); // one texel width
if (u < w || u > 1.0 - w)
{
float t = frac(u + w) / (2.0 * w); // 0 at right edge, 1 at left edge
float4 cRight = image.Sample(textureSampler, float2(1.0 - 0.5 * w, v));
float4 cLeft = image.Sample(textureSampler, float2(0.5 * w, v));
return lerp(cRight, cLeft, t);
}
return image.Sample(textureSampler, float2(u, v));
}
Streamer.bot: the script
- Actions tab β right-click β Add β name it Cam360 Control.
- In its Sub-Actions pane: right-click β Core β C# β Execute C# Code.
- Delete all the placeholder code and paste in the entire script below. Don't paste it inside the existing template β replace everything.
- Edit the config block at the top: SourceName and FilterName must match OBS exactly (case, spaces, everything). ObsConnection is 0 if OBS is your first/only connection in Stream Apps.
- Click the References tab and confirm these exist (add via "Add from Assembly Name" if missing): mscorlib.dll, System.dll, System.Core.dll.
- Compile β look for "Compiled Successfully" β Save and Compile β OK.
using System;
using System.Globalization;
using System.Threading;
// ============================================================
// Mushbox Cam360 Control β v4.5 (adds corkscrew flip+roll)
// Built on v4.2. Writes two new shader uniforms: "projection"
// (0 = normal reframe, 1 = stereographic tiny planet) and
// "planet_fov" (how much sphere wraps; bigger = smaller planet).
// Requires mushbox-360-reframe shader v2.4 or newer.
//
// camAction / !cam values:
// front|back|left|right -> pan to heading, re-level; EXITS planet
// up|down|level -> pitch presets; EXITS planet
// tilt <-90..90> -> pitch to exact angle; EXITS planet
// roll <-180..180> -> dutch angle; EXITS planet
// barrelroll -> full 360 roll (keeps current mode)
// corkscrew -> flip + roll (+ yaw) tumble, lands level
// <degrees> -> yaw only; EXITS planet
// spin -> full 360 yaw spin (keeps current mode;
// in planet mode this spins the globe)
// zoomin|zoomout -> step FOV (normal) OR planet wrap (planet)
// zoom <fov> -> exact FOV (normal) OR wrap (planet)
// zoomreset -> reset FOV (normal) OR wrap (planet)
// planet -> toggle tiny planet on/off
// planet on|off -> force tiny planet on/off
// planet in|out -> bigger / smaller planet
// planet reset -> planet wrap back to default
// planet <deg> -> enter planet + set wrap (160..320)
//
// Commands: !cam <value>, !spin, !zoom <...>, !barrelroll, !corkscrew, !planet <...>
// (You can add a dedicated !planet command in the Commands tab, OR
// just use "!cam planet ..." with no new trigger β both route here.)
//
// Clamping: chat zoom/wrap use the tighter Chat* ranges; deck
// wrappers (camAction) get the full Deck* ranges.
// ============================================================
public class CPHInline
{
// ======= EDIT THESE TO MATCH YOUR OBS SETUP =======
private const string SourceName = "Insta360 X5";
private const string FilterName = "User-defined shader";
private const int ObsConnection = 0;
// ======= ANIMATION TUNING =======
private const int StepMs = 25;
private const double PanDurationSec = 1.5;
private const double SpinDurationSec = 4.0;
private const double ZoomDurationSec = 1.0;
private const double BarrelRollDurationSec = 2.5;
private const double CorkscrewDurationSec = 3.0; // flip + roll tumble
private const double CorkscrewYawTurns = 1.0; // full turns per axis
private const double CorkscrewPitchTurns = 1.0; // (set Yaw=0 for pure flip+roll,
private const double CorkscrewRollTurns = 1.0; // Pitch=2 for a double flip, etc.)
private const double PlanetDurationSec = 1.2; // wrap grow/shrink tween
private const bool ChatFeedback = true;
// ======= ZOOM TUNING (rectilinear FOV) =======
private const double DefaultFov = 115.0;
private const double ZoomStep = 15.0;
private const double DeckFovMin = 30.0;
private const double DeckFovMax = 150.0;
private const double ChatFovMin = 60.0;
private const double ChatFovMax = 120.0;
// ======= TINY-PLANET TUNING (stereographic wrap in degrees) =======
// Higher wrap = smaller planet with more sky ring.
private const double DefaultPlanetFov = 250.0;
private const double PlanetFovStep = 20.0;
private const double DeckPlanetMin = 160.0;
private const double DeckPlanetMax = 320.0;
private const double ChatPlanetMin = 190.0;
private const double ChatPlanetMax = 300.0;
private const string YawVar = "cam360_yaw";
private const string PitchVar = "cam360_pitch";
private const string FovVar = "cam360_fov";
private const string RollVar = "cam360_roll";
private const string ProjVar = "cam360_proj"; // 0 or 1
private const string PlanetFovVar = "cam360_planetfov"; // degrees
// Projection mode being applied by the CURRENT move. SendAll reads this.
private double _proj = 0.0;
// When false, SendAll does NOT clamp pitch to +/-90 (used by the corkscrew flip).
private bool _pitchClamp = true;
public bool Execute()
{
string camAction = "";
if (args.ContainsKey("camAction") && args["camAction"] != null)
camAction = args["camAction"].ToString().Trim().ToLowerInvariant();
string command = "";
if (args.ContainsKey("command") && args["command"] != null)
command = args["command"].ToString().Trim().ToLowerInvariant();
string input = "";
if (args.ContainsKey("rawInput") && args["rawInput"] != null)
input = args["rawInput"].ToString().Trim().ToLowerInvariant();
// Deck / wrapper argument takes priority (trusted: full ranges)
if (!string.IsNullOrEmpty(camAction))
{
RunNamedMove(camAction, false);
return true;
}
// No command = event trigger (e.g. Follow) -> spin
if (string.IsNullOrEmpty(command))
{
DoSpin();
return true;
}
if (command == "!spin")
{
DoSpin();
return true;
}
if (command == "!barrelroll")
{
DoBarrelRoll();
return true;
}
if (command == "!corkscrew")
{
DoCorkscrew();
return true;
}
if (command == "!planet")
{
HandlePlanet(input, true);
return true;
}
if (command == "!zoom")
{
if (input == "")
{
if (ChatFeedback)
CPH.SendMessage("Usage: !zoom 60-120, !zoom in, !zoom out, or !zoom reset");
return true;
}
if (input == "in") RunNamedMove("zoomin", true);
else if (input == "out") RunNamedMove("zoomout", true);
else if (input == "reset") RunNamedMove("zoomreset", true);
else RunNamedMove("zoom " + input, true);
return true;
}
if (command == "!cam")
{
if (input == "")
{
if (ChatFeedback)
CPH.SendMessage("Usage: !cam front | back | left | right | up | down | level, !cam <degrees>, !cam tilt <angle>, !cam roll <angle>, !cam barrelroll, !cam corkscrew, !cam zoom <fov>, !cam zoomin | zoomout | zoomreset, !cam planet <on|off|in|out|reset|degrees>");
return true;
}
RunNamedMove(input, true);
return true;
}
return true;
}
// ================= MOVE DISPATCH =================
private void RunNamedMove(string move, bool fromChat)
{
string[] parts = move.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
string head = parts.Length > 0 ? parts[0] : "";
string arg = parts.Length > 1 ? parts[1] : "";
double target;
switch (head)
{
case "spin": DoSpin(); return;
case "barrelroll": DoBarrelRoll(); return;
case "corkscrew": DoCorkscrew(); return;
case "planet": HandlePlanet(arg, fromChat); return;
case "front": MoveTo(0.0, 0.0); return; // heading + level, exits planet
case "back": MoveTo(180.0, 0.0); return;
case "left": MoveTo(-90.0, 0.0); return;
case "right": MoveTo(90.0, 0.0); return;
case "up": PitchOnly(90.0); return;
case "down": PitchOnly(-90.0); return;
case "level": LevelAll(); return;
case "tilt":
if (double.TryParse(arg, NumberStyles.Float, CultureInfo.InvariantCulture, out target))
{
PitchOnly(Clamp90(target));
}
else if (fromChat && ChatFeedback)
{
CPH.SendMessage("Usage: !cam tilt <-90 to 90>, e.g. !cam tilt 45");
}
return;
case "roll":
if (double.TryParse(arg, NumberStyles.Float, CultureInfo.InvariantCulture, out target))
{
RollTo(Wrap180(target));
}
else if (fromChat && ChatFeedback)
{
CPH.SendMessage("Usage: !cam roll <-180 to 180>, e.g. !cam roll 15 (or !cam barrelroll)");
}
return;
case "zoomin":
if (InPlanet()) PlanetWrapTo(GetStoredPlanetFov() - PlanetFovStep, fromChat);
else ZoomTo(GetStoredFov() - ZoomStep, fromChat);
return;
case "zoomout":
if (InPlanet()) PlanetWrapTo(GetStoredPlanetFov() + PlanetFovStep, fromChat);
else ZoomTo(GetStoredFov() + ZoomStep, fromChat);
return;
case "zoomreset":
if (InPlanet()) PlanetWrapTo(DefaultPlanetFov, false);
else ZoomTo(DefaultFov, false);
return;
case "zoom":
if (double.TryParse(arg, NumberStyles.Float, CultureInfo.InvariantCulture, out target))
{
if (InPlanet()) PlanetWrapTo(target, fromChat);
else ZoomTo(target, fromChat);
}
else if (fromChat && ChatFeedback)
{
if (InPlanet())
CPH.SendMessage("Planet mode: !cam zoom " + (int)ChatPlanetMin + "-" + (int)ChatPlanetMax + " sets planet size, or !cam planet off");
else
CPH.SendMessage("Usage: !cam zoom " + (int)ChatFovMin + "-" + (int)ChatFovMax + ", e.g. !cam zoom 80");
}
return;
default:
if (!double.TryParse(head, NumberStyles.Float, CultureInfo.InvariantCulture, out target))
{
if (fromChat && ChatFeedback)
CPH.SendMessage("Couldn't read that. Try: !cam front | back | left | right | up | down | level, !cam 135, !cam tilt 45, !cam roll 15, !cam zoom 90, or !cam planet");
return;
}
YawOnly(target);
return;
}
}
// ================= CAMERA MOVES (rectilinear -> exit planet) =================
private void MoveTo(double targetYaw, double targetPitch)
{
_proj = 0.0;
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double yawDelta = Wrap180(targetYaw - fromYaw);
double pitchDelta = Clamp90(targetPitch) - fromPitch;
double rollDelta = Wrap180(0.0 - fromRoll);
Animate(fromYaw, yawDelta, fromPitch, pitchDelta, fromFov, 0.0, fromRoll, rollDelta, PanDurationSec);
}
private void YawOnly(double targetYaw)
{
_proj = 0.0;
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double yawDelta = Wrap180(targetYaw - fromYaw);
Animate(fromYaw, yawDelta, fromPitch, 0.0, fromFov, 0.0, fromRoll, 0.0, PanDurationSec);
}
private void PitchOnly(double targetPitch)
{
_proj = 0.0;
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double pitchDelta = Clamp90(targetPitch) - fromPitch;
Animate(fromYaw, 0.0, fromPitch, pitchDelta, fromFov, 0.0, fromRoll, 0.0, PanDurationSec);
}
private void RollTo(double targetRoll)
{
_proj = 0.0;
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double rollDelta = Wrap180(targetRoll - fromRoll);
Animate(fromYaw, 0.0, fromPitch, 0.0, fromFov, 0.0, fromRoll, rollDelta, PanDurationSec);
}
private void LevelAll()
{
_proj = 0.0;
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double pitchDelta = 0.0 - fromPitch;
double rollDelta = Wrap180(0.0 - fromRoll);
Animate(fromYaw, 0.0, fromPitch, pitchDelta, fromFov, 0.0, fromRoll, rollDelta, PanDurationSec);
}
private void ZoomTo(double targetFov, bool fromChat)
{
_proj = 0.0;
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double clamped = fromChat
? ClampRange(targetFov, ChatFovMin, ChatFovMax)
: ClampRange(targetFov, DeckFovMin, DeckFovMax);
double fovDelta = clamped - fromFov;
Animate(fromYaw, 0.0, fromPitch, 0.0, fromFov, fovDelta, fromRoll, 0.0, ZoomDurationSec);
}
// ================= SPIN / BARREL ROLL (keep current mode) =================
private void DoSpin()
{
_proj = GetStoredProjection();
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
Animate(fromYaw, 360.0, fromPitch, 0.0, fromFov, 0.0, fromRoll, 0.0, SpinDurationSec);
}
private void DoBarrelRoll()
{
_proj = GetStoredProjection();
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
Animate(fromYaw, 0.0, fromPitch, 0.0, fromFov, 0.0, fromRoll, 360.0, BarrelRollDurationSec);
}
// Corkscrew: flip (pitch) + roll (+ optional yaw) all at once, then land level.
// Needs the pitch clamp lifted so the flip can go over the top past 90 degrees.
private void DoCorkscrew()
{
_proj = 0.0; // a tumble only reads correctly in the normal (rectilinear) view
_pitchClamp = false; // let pitch travel the full turn instead of stopping at +/-90
double fromYaw = GetStored(YawVar);
double fromPitch = GetStored(PitchVar);
double fromFov = GetStoredFov();
double fromRoll = GetStored(RollVar);
double heldPlanet = GetStoredPlanetFov();
double yawDelta = 360.0 * CorkscrewYawTurns;
double pitchDelta = 360.0 * CorkscrewPitchTurns;
double rollDelta = 360.0 * CorkscrewRollTurns;
int totalSteps = Math.Max(1, (int)(CorkscrewDurationSec * 1000.0 / StepMs));
for (int i = 1; i <= totalSteps; i++)
{
double t = (double)i / totalSteps;
double eased = t * t * (3.0 - 2.0 * t);
SendAll(fromYaw + yawDelta * eased,
fromPitch + pitchDelta * eased, // sent unclamped while _pitchClamp == false
fromFov,
fromRoll + rollDelta * eased,
heldPlanet);
Thread.Sleep(StepMs);
}
// Whole turns net to zero, so land exactly where we started, level and clamped again.
_pitchClamp = true;
double finalYaw = Wrap180(fromYaw + yawDelta);
double finalPitch = Clamp90(fromPitch);
double finalRoll = Wrap180(fromRoll + rollDelta);
SendAll(finalYaw, finalPitch, fromFov, finalRoll, heldPlanet);
CPH.SetGlobalVar(YawVar, finalYaw, false);
CPH.SetGlobalVar(PitchVar, finalPitch, false);
CPH.SetGlobalVar(FovVar, fromFov, false);
CPH.SetGlobalVar(RollVar, finalRoll, false);
CPH.SetGlobalVar(ProjVar, _proj, false);
CPH.SetGlobalVar(PlanetFovVar, heldPlanet, false);
}
// ================= TINY PLANET =================
private void HandlePlanet(string arg, bool fromChat)
{
arg = (arg ?? "").Trim();
if (arg == "")
{
PlanetToggle();
return;
}
if (arg == "on")
{
PlanetSnap(1.0, GetStoredPlanetFov());
if (ChatFeedback) CPH.SendMessage("Tiny planet ON β !planet out for a smaller planet, !spin to rotate it");
return;
}
if (arg == "off")
{
PlanetSnap(0.0, GetStoredPlanetFov());
if (ChatFeedback) CPH.SendMessage("Tiny planet OFF");
return;
}
if (arg == "reset")
{
PlanetWrapTo(DefaultPlanetFov, false);
return;
}
if (arg == "in") // punch in -> bigger planet -> lower wrap
{
PlanetWrapTo(GetStoredPlanetFov() - PlanetFovStep, fromChat);
return;
}
if (arg == "out") // pull back -> smaller planet -> higher wrap
{
PlanetWrapTo(GetStoredPlanetFov() + PlanetFovStep, fromChat);
return;
}
double val;
if (double.TryParse(arg, NumberStyles.Float, CultureInfo.InvariantCulture, out val))
{
PlanetWrapTo(val, fromChat);
}
else if (fromChat && ChatFeedback)
{
CPH.SendMessage("Usage: !planet (toggle), !planet on|off, !planet in|out, !planet reset, !planet <" + (int)ChatPlanetMin + "-" + (int)ChatPlanetMax + ">");
}
}
private void PlanetToggle()
{
if (InPlanet())
{
PlanetSnap(0.0, GetStoredPlanetFov());
if (ChatFeedback) CPH.SendMessage("Tiny planet OFF");
}
else
{
PlanetSnap(1.0, GetStoredPlanetFov());
if (ChatFeedback) CPH.SendMessage("Tiny planet ON β !planet out for a smaller planet, !spin to rotate it");
}
}
// Instant projection switch (can't tween between projections). Holds
// yaw/pitch/fov/roll, sets projection + wrap.
private void PlanetSnap(double proj, double wrap)
{
_proj = (proj >= 0.5) ? 1.0 : 0.0;
double y = GetStored(YawVar);
double p = GetStored(PitchVar);
double f = GetStoredFov();
double r = GetStored(RollVar);
double w = ClampRange(wrap, DeckPlanetMin, DeckPlanetMax);
SendAll(y, p, f, r, w);
CPH.SetGlobalVar(ProjVar, _proj, false);
CPH.SetGlobalVar(PlanetFovVar, w, false);
}
// Smoothly grow/shrink the planet. Implies planet mode ON.
private void PlanetWrapTo(double targetWrap, bool fromChat)
{
_proj = 1.0;
double fromWrap = GetStoredPlanetFov();
double clamped = fromChat
? ClampRange(targetWrap, ChatPlanetMin, ChatPlanetMax)
: ClampRange(targetWrap, DeckPlanetMin, DeckPlanetMax);
double y = GetStored(YawVar);
double p = GetStored(PitchVar);
double f = GetStoredFov();
double r = GetStored(RollVar);
double delta = clamped - fromWrap;
int totalSteps = Math.Max(1, (int)(PlanetDurationSec * 1000.0 / StepMs));
for (int i = 1; i <= totalSteps; i++)
{
double t = (double)i / totalSteps;
double eased = t * t * (3.0 - 2.0 * t);
SendAll(y, p, f, r, fromWrap + delta * eased);
Thread.Sleep(StepMs);
}
SendAll(y, p, f, r, clamped);
CPH.SetGlobalVar(ProjVar, 1.0, false);
CPH.SetGlobalVar(PlanetFovVar, clamped, false);
}
// ================= ANIMATION CORE =================
private void Animate(double fromYaw, double yawDelta, double fromPitch, double pitchDelta,
double fromFov, double fovDelta, double fromRoll, double rollDelta,
double durationSec)
{
double heldPlanet = GetStoredPlanetFov(); // wrap doesn't change on these moves
int totalSteps = Math.Max(1, (int)(durationSec * 1000.0 / StepMs));
for (int i = 1; i <= totalSteps; i++)
{
double t = (double)i / totalSteps;
double eased = t * t * (3.0 - 2.0 * t);
SendAll(fromYaw + yawDelta * eased,
fromPitch + pitchDelta * eased,
fromFov + fovDelta * eased,
fromRoll + rollDelta * eased,
heldPlanet);
Thread.Sleep(StepMs);
}
double finalYaw = Wrap180(fromYaw + yawDelta);
double finalPitch = Clamp90(fromPitch + pitchDelta);
double finalFov = ClampRange(fromFov + fovDelta, DeckFovMin, DeckFovMax);
double finalRoll = Wrap180(fromRoll + rollDelta);
SendAll(finalYaw, finalPitch, finalFov, finalRoll, heldPlanet);
CPH.SetGlobalVar(YawVar, finalYaw, false);
CPH.SetGlobalVar(PitchVar, finalPitch, false);
CPH.SetGlobalVar(FovVar, finalFov, false);
CPH.SetGlobalVar(RollVar, finalRoll, false);
CPH.SetGlobalVar(ProjVar, _proj, false);
CPH.SetGlobalVar(PlanetFovVar, heldPlanet, false);
}
// ================= OBS PLUMBING =================
private void SendAll(double yaw, double pitch, double fov, double roll, double planetFov)
{
string json =
"{\"sourceName\":\"" + Escape(SourceName) + "\"," +
"\"filterName\":\"" + Escape(FilterName) + "\"," +
"\"filterSettings\":{" +
"\"yaw\":" + Wrap180(yaw).ToString("F2", CultureInfo.InvariantCulture) + "," +
"\"pitch\":" + (_pitchClamp ? Clamp90(pitch) : pitch).ToString("F2", CultureInfo.InvariantCulture) + "," +
"\"fov\":" + ClampRange(fov, DeckFovMin, DeckFovMax).ToString("F2", CultureInfo.InvariantCulture) + "," +
"\"roll\":" + Wrap180(roll).ToString("F2", CultureInfo.InvariantCulture) + "," +
"\"projection\":" + _proj.ToString("F0", CultureInfo.InvariantCulture) + "," +
"\"planet_fov\":" + ClampRange(planetFov, DeckPlanetMin, DeckPlanetMax).ToString("F2", CultureInfo.InvariantCulture) +
"}}";
CPH.ObsSendRaw("SetSourceFilterSettings", json, ObsConnection);
}
private double GetStored(string varName)
{
try { return CPH.GetGlobalVar<double>(varName, false); }
catch { return 0.0; }
}
// FOV fallback: an unset global reads as 0, which is not a valid FOV.
private double GetStoredFov()
{
double f = GetStored(FovVar);
if (f < DeckFovMin || f > DeckFovMax) return DefaultFov;
return f;
}
// Planet wrap fallback: unset/out-of-range -> default.
private double GetStoredPlanetFov()
{
double f = GetStored(PlanetFovVar);
if (f < DeckPlanetMin || f > DeckPlanetMax) return DefaultPlanetFov;
return f;
}
private double GetStoredProjection()
{
return (GetStored(ProjVar) >= 0.5) ? 1.0 : 0.0;
}
private bool InPlanet()
{
return GetStoredProjection() >= 0.5;
}
// ================= HELPERS =================
private static double ClampRange(double v, double min, double max)
{
return Math.Max(min, Math.Min(max, v));
}
private static double Wrap180(double degrees)
{
double d = degrees % 360.0;
if (d > 180.0) d -= 360.0;
if (d < -180.0) d += 360.0;
return d;
}
private static double Clamp90(double degrees)
{
return Math.Max(-90.0, Math.Min(90.0, degrees));
}
private static string Escape(string s)
{
return s.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
}
Commands & triggers
- Commands tab β right-click β Add β create !cam. Set Location to Starts With β not Exact. Exact silently ignores !cam 90 because of the argument.
- Create !zoom the same way β Starts With (it takes arguments like !zoom 80).
- Create !spin, !barrelroll, and !corkscrew (Exact is fine β no arguments). Make sure all commands are Enabled.
- Tiny planet (optional): create !planet β Starts With (it takes arguments like !planet out). Skip it if you like; !cam planet ... drives the same thing with no extra command.
- In the Cam360 Control action, right-click the Triggers pane β Twitch β Chat β Command β select !cam. Repeat for !zoom, !spin, !barrelroll, !corkscrew, and !planet (if you added it).
- For the follower spin: right-click Triggers again β Twitch β Channel β Follow. The script treats any trigger without a chat command as a spin, so followers spin the camera automatically.
Command reference
| Command | What it does |
|---|---|
| !cam front | Pan to 0Β° (camera forward) |
| !cam back | Pan to 180Β° |
| !cam left | Pan to -90Β° |
| !cam right | Pan to 90Β° |
| !cam 135 | Pan to any absolute angle |
| !cam up / !cam down | Tilt straight up / straight down |
| !cam level | Return pitch and roll to level |
| !cam tilt 45 | Tilt to any exact angle (-90 to 90) |
| !cam roll 15 | Hold a dutch angle (-180 to 180); roll 0 straightens out |
| !barrelroll | Full 360Β° roll, ends where it started |
| !corkscrew | Flip + roll (+ spin) tumble, lands level; forces normal view for the move |
| !zoom 80 | Zoom to an exact FOV β lower = punched in (chat clamped 60β120) |
| !zoom in / !zoom out | Step the zoom by 15Β° of FOV |
| !zoom reset | Glide back to the default FOV |
| !spin | Full 360Β° spin, ends where it started (holds tilt + zoom) |
| !planet | Toggle stereographic tiny-planet view on / off |
| !planet on / !planet off | Force tiny planet on or off |
| !planet in / !planet out | Bigger planet / smaller planet with more sky. In planet mode !zoom reroutes here |
| !planet 280 | Enter planet and set size directly (chat 190β300) |
| !planet reset | Planet size back to the default (250Β°) |
| new follower | Automatic full spin |
The direction presets (front/back/left/right) also return pitch and roll to level in one smooth move β one preset always rescues a camera chat has left crooked. Zoom holds across pans until changed or reset.
Tuning lives at the top of the script: PanDurationSec, SpinDurationSec, ZoomDurationSec, BarrelRollDurationSec, StepMs, ChatFeedback (false silences chat replies), plus the zoom block: DefaultFov (your resting FOV), ZoomStep, and the chat FOV clamp (ChatFovMin/ChatFovMax, default 60β120 so chat canβt mush-zoom or fisheye the stream). Deck buttons get the full 30β150 range.
Tiny planet mode
Tiny planet is a separate stereographic projection β not just pitching down and zooming out, since a normal rectilinear view physically can't wrap past 180Β°. Flip it on and the camera looks straight down at the nadir with the horizon (and some sky) curled into a ring around a little globe. Needs shader v2.4+ and script v4.4+; the Projection switch is an instant cut (you can't tween between two projections), but growing and shrinking the planet animates smoothly.
- Any normal framing command β !cam front, tilt, a heading, etc. β automatically snaps back to the normal view, so chat can never get stuck in planet mode.
- In planet mode, !spin rotates the globe instead of panning, and !zoom in / !zoom out are rerouted to planet size.
- Higher Tiny-planet wrap = smaller planet with more sky. Tune the resting value with DefaultPlanetFov at the top of the script.
The queue β don't skip this
If a follow spin fires while a !cam move is mid-animation, both threads fight over the yaw value and the camera stutters. The fix takes 30 seconds:
- Settings β Action Queues β Add.
- Name it camera and leave Blocking checked (runs one action at a time).
- In the Cam360 Control action's settings, set its Queue to camera.
Stacked events now wait their turn instead of colliding. Any future camera actions (raid spins, sub moves) should go on this same queue.
Stream Deck / Decks buttons
Deck buttons trigger actions directly with no chat command attached, so pointing a deck button straight at Cam360 Control would always fall into the "no command = spin" path. The fix: keep the core action as the engine, and create one tiny wrapper action per button that passes it a camAction argument. The script above already understands this argument.
- Create a new action per button, e.g. Cam Left, Cam Right, Cam Front, Cam Back, Cam Spin.
- In each wrapper, add sub-action Core β Arguments β Set Argument β Name: camAction, Value: left (or right, front, back, up, down, level, spin, barrelroll, corkscrew, zoomin, zoomout, zoomreset, any number like 135, or two-word values like tilt 45, roll 15, zoom 90 β deck buttons get the full 30β150 FOV range).
- Add sub-action Core β Actions β Run Action β select Cam360 Control β leave "Run Immediately" UNCHECKED. Unchecked routes the move through the blocking camera queue, so mashing buttons queues moves politely instead of fighting over the yaw.
- In the Decks editor, point each button at its wrapper action. The wrappers need no triggers and no queue of their own.
Test it
- From your own chat: !cam left, !cam back, !cam 135, !cam tilt 45, !zoom 80, !barrelroll, !cam front (should re-level but hold the zoom), !zoom reset, !spin, !corkscrew.
- Test the follow trigger without a real follower: right-click the action β Test Trigger.
- Expected: smooth ~1.5-second eased pans always taking the shortest direction, ~1-second zooms, a 2.5-second barrel roll and a 4-second spin that both end exactly where they started.
Getting the best image quality
A 360 camera spreads its pixels across an entire sphere, and this view only shows a slice of them β at ~95Β° FOV you're seeing roughly a quarter of the horizontal resolution stretched to full frame. It will never match a dedicated webcam, but these settings close most of the gap:
- Capture at full resolution. Uncheck "Use Preset" and set 2880Γ1440 manually (Part 2). This is the single biggest factor.
- Widen the FOV. Every degree wider uses more source pixels. 110β120Β° is noticeably sharper than 90Β°, with a slight fisheye look. Avoid narrow "zoomed" FOVs as a resting state.
- Lanczos scaling. Right-click the source β Scale Filtering β Lanczos. The default is soft.
- Mild sharpening. Add OBS's built-in Sharpen filter after the shader filter, around 0.08β0.15.
- Light the room well. Small 360 sensors get noisy in dim light, and noise plus upscaling reads as blur on stream.
- Feed the encoder. Spins are full-frame motion. Bump your bitrate or step your encoder preset up in quality β smearing during moves is usually bitrate starvation.
Troubleshooting
In the order things actually break:
Script won't compile β errors about dynamic binding or missing references
Add System.Core.dll in the References tab of the Execute C# Code window ("Add from Assembly Name"). This is the most common failure by a wide margin.
Commands do nothing, no errors anywhere
1) Check the !cam command's Location is Starts With, not Exact. 2) Check the command is Enabled and its trigger is actually attached to the action. 3) Check Stream Apps β OBS shows connected (green).
Compiles and triggers fire, but the camera doesn't move
SourceName or FilterName in the script doesn't exactly match OBS. Copy-paste the names from OBS directly into the script constants β one wrong space breaks it silently.
Changed DefaultFov (or another default) but OBS snaps back to the old value
The script remembers its last position in Streamer.bot global variables (cam360_yaw, cam360_pitch, cam360_fov, cam360_roll) and those win over the constants. After changing DefaultFov, run !zoom reset once to store the new value β or just restart Streamer.bot, which clears the globals so the first move falls back to your new defaults. Same logic applies after moving the sliders by hand in OBS: the script doesnβt see manual slider changes and will snap back on the next move.
Camera jumps or stutters when events overlap
You skipped Part 6. Set up the blocking camera queue and assign the action to it.
Visible seam or heavy distortion in the view
Thin vertical line exactly when facing the rear (yaw 180): you're running the original (v1) shader. The v2.1 shader in this guide fixes it with a horizontal-wrap sampler β re-copy the shader above into your .shader file and reload the filter. Ghosting or misalignment on close objects at the sides (Β±90Β°): that's the camera's optical stitch line where the two lenses meet, worst on close subjects. Fix is physical: rotate the camera so the lenses face what matters and the stitch zones land somewhere boring. Everything looks warped: confirm the camera is outputting equirectangular (a 2:1 resolution like 2880Γ1440), not a fisheye or already-reframed mode.
Chat commands animate from the wrong starting position
The script tracks yaw itself (starting at 0). If you drag the OBS slider by hand, the next chat command animates from the script's last known angle, not the slider's. Run any !cam command and it re-syncs from there.
OBS WebSocket connection refused
Confirm OBS WebSocket is enabled on port 4455 and the password in Streamer.bot matches. If another app is squatting on the port, change it in both places.