feat(oscope-of): 3D Tunnel + Mesh visualizers

Context: the 8 modes added in the previous commit are all 2D. For
visual variety on stage we want true depth — a tunnel that flies past
the audience and a deformable 3D object that you can read as a body.
Both should react to the same OSC bus the rest of the system uses.

Approach: Tunnel is a closed-form pseudo-3D fragment shader (1/r depth
mapping, no real raymarch needed for an infinite straight tube), so
it's GPU-cheap and works under PostFx like every other mode. Mesh uses
real geometry — ofMesh::icosphere subdivided 4x, vertices displaced
each frame from a 32-band envelope of the channel waveform. ofCamera
gives a proper perspective; we draw inside its viewport so the rest
of the app (HUD, GUI panels, post-fx) keeps its 2D pipeline.

Changes:
- visualizers/TunnelVis.{h,cpp} + shaders/tunnel.{vert,frag} :
  travel speed = bpm * 0.012 + kick, twist = depth * lead amplitude,
  brick pattern (ang × slice), bright lamps every 4 slices that pulse
  on kick, IQ palette cycles along depth, distance fog modulated by
  pad
- visualizers/MeshVis.{h,cpp} : icosphere with 32-band RMS displacement
  per vertex (stable hash from vertex position picks the band),
  pulse-on-beat envelope (0.18 lerp back), self-rotation modulated by
  bpm, draws three layers in additive blend mode (dim wireframe back,
  solid HSB sphere, bright inner wireframe at 0.85x), camera viewport
  isolated via ofRectangle so it doesn't break the 2D pipeline,
  synthetic band envelope when scope buffer is empty
- ofApp : two new Mode enum entries (Tunnel, Mesh), keys '0' and '-'
  to select them, factory-reload includes tunnel shader, default_mode
  config supports 'tunnel' and 'mesh', Hybrid mode now uses the 3D
  tunnel as background and the 3D mesh as one of its quadrants
  (replacing the 2D reactive duplicate)

Impact: 11 modes total (was 9), of which 2 are real 3D. Both compose
with the post-fx chain so chromatic / kaleido / feedback / glitch
apply to them too. Build clean on Intel MBP / Big Sur with the same
make OF_ROOT=$HOME/of Release flow.
This commit is contained in:
L'électron rare
2026-05-07 13:59:12 +02:00
parent 98b0535d70
commit db0a01a6dc
6 changed files with 244 additions and 3 deletions
+18 -3
View File
@@ -44,6 +44,8 @@ const char* modeName(ofApp::Mode m) {
case ofApp::Mode::Plasma: return "Plasma";
case ofApp::Mode::Particles: return "Particles";
case ofApp::Mode::Kaleido: return "Kaleido";
case ofApp::Mode::Tunnel: return "Tunnel 3D";
case ofApp::Mode::Mesh: return "Mesh 3D";
case ofApp::Mode::Hybrid: return "Hybrid";
}
return "?";
@@ -75,6 +77,8 @@ void ofApp::setup() {
plasma_ = std::make_unique<oscope::PlasmaVis>();
particles_ = std::make_unique<oscope::ParticleVis>();
kaleido_ = std::make_unique<oscope::KaleidoVis>();
tunnel_ = std::make_unique<oscope::TunnelVis>();
mesh_ = std::make_unique<oscope::MeshVis>();
lissajous_->setup(W, H);
spectro_->setup(W, H / 4);
reactive_->setup(W, H);
@@ -83,6 +87,8 @@ void ofApp::setup() {
plasma_->setup(W, H);
particles_->setup(W, H);
kaleido_->setup(W, H);
tunnel_->setup(W, H);
mesh_->setup(W, H);
postfx_.setup(W, H);
@@ -137,6 +143,8 @@ void ofApp::loadSettings() {
else if (m == "plasma") mode_ = Mode::Plasma;
else if (m == "particles") mode_ = Mode::Particles;
else if (m == "kaleido") mode_ = Mode::Kaleido;
else if (m == "tunnel") mode_ = Mode::Tunnel;
else if (m == "mesh") mode_ = Mode::Mesh;
else mode_ = Mode::Hybrid;
}
@@ -186,6 +194,8 @@ void ofApp::update() {
plasma_->update(frame);
particles_->update(frame);
kaleido_->update(frame);
tunnel_->update(frame);
mesh_->update(frame);
applyOscFx();
@@ -229,13 +239,15 @@ void ofApp::drawMode(Mode m, int x, int y, int w, int h) {
particles_->draw(x, y, w, h);
break;
case Mode::Kaleido: kaleido_->draw(x, y, w, h); break;
case Mode::Tunnel: tunnel_->draw(x, y, w, h); break;
case Mode::Mesh: mesh_->draw(x, y, w, h); break;
case Mode::Hybrid: drawHybrid(w, h); break;
}
}
void ofApp::drawHybrid(int W, int H) {
// Background plasma stretches across the full canvas
plasma_->draw(0, 0, W, H);
// Background : the 3D tunnel fills the canvas
tunnel_->draw(0, 0, W, H);
// 2x2 quadrant overlay with additive blending
ofEnableBlendMode(OF_BLENDMODE_ADD);
@@ -244,7 +256,7 @@ void ofApp::drawHybrid(int W, int H) {
lissajous_->draw(0, 0, hw, hh);
polar_->draw (hw, 0, hw, hh);
kaleido_->draw (0, hh, hw, hh);
reactive_->draw(hw, hh, hw, hh);
mesh_->draw (hw, hh, hw, hh);
ofDisableBlendMode();
// Particle field on top, fullscreen
@@ -307,6 +319,8 @@ void ofApp::keyPressed(int key) {
case '7': mode_ = Mode::Plasma; break;
case '8': mode_ = Mode::Particles; break;
case '9': mode_ = Mode::Kaleido; break;
case '0': mode_ = Mode::Tunnel; break;
case '-': mode_ = Mode::Mesh; break;
case 'f':
fullscreen_ = !fullscreen_;
ofSetFullscreen(fullscreen_);
@@ -319,6 +333,7 @@ void ofApp::keyPressed(int key) {
lissajous_->reloadShaders();
reactive_->reloadShaders();
plasma_->reloadShaders();
tunnel_->reloadShaders();
postfx_.reloadShader();
ofLogNotice("ofApp") << "Shaders rechargés";
break;
+5
View File
@@ -21,6 +21,8 @@
#include "visualizers/PlasmaVis.h"
#include "visualizers/ParticleVis.h"
#include "visualizers/KaleidoVis.h"
#include "visualizers/TunnelVis.h"
#include "visualizers/MeshVis.h"
#include <memory>
@@ -29,6 +31,7 @@ public:
enum class Mode {
Lissajous, Spectrogram, Reactive, Waveform,
Polar, Plasma, Particles, Kaleido,
Tunnel, Mesh,
Hybrid
};
@@ -58,6 +61,8 @@ private:
std::unique_ptr<oscope::PlasmaVis> plasma_;
std::unique_ptr<oscope::ParticleVis> particles_;
std::unique_ptr<oscope::KaleidoVis> kaleido_;
std::unique_ptr<oscope::TunnelVis> tunnel_;
std::unique_ptr<oscope::MeshVis> mesh_;
std::vector<float> ch1_, ch2_;
Mode mode_ = Mode::Hybrid;
+123
View File
@@ -0,0 +1,123 @@
#include "MeshVis.h"
namespace oscope {
void MeshVis::setup(int w, int h) {
w_ = w; h_ = h;
sphereBase_ = ofMesh::icosphere(1.0f, 4);
sphereDeformed_ = sphereBase_;
cam_.setPosition(0.0f, 0.0f, 3.5f);
cam_.lookAt(glm::vec3(0.0f));
cam_.setNearClip(0.05f);
cam_.setFarClip(100.0f);
bandEnergy_.assign(32, 0.0f);
}
void MeshVis::update(const VisFrame& frame) {
bpm_ = frame.osc.bpm();
kick_ = frame.osc.amp("kick");
bass_ = frame.osc.amp("bass");
lead_ = frame.osc.amp("lead");
pad_ = frame.osc.amp("pad");
yaw_ += static_cast<float>(ofGetLastFrameTime()) * (0.18f + bpm_ * 0.0024f);
pitch_ += static_cast<float>(ofGetLastFrameTime()) * 0.07f;
if (frame.osc.beatPulse()) pulseScale_ = 1.0f + 0.45f * (0.4f + kick_);
pulseScale_ += (1.0f - pulseScale_) * 0.18f;
// Band energy from ch1 (32 buckets, RMS within each)
const auto& s = frame.ch1;
if (!s.empty()) {
const std::size_t N = s.size();
const std::size_t band = std::max<std::size_t>(1, N / bandEnergy_.size());
for (std::size_t b = 0; b < bandEnergy_.size(); ++b) {
float acc = 0.0f;
for (std::size_t i = 0; i < band && b * band + i < N; ++i) {
float v = s[b * band + i];
acc += v * v;
}
float rms = std::sqrt(acc / std::max<std::size_t>(1, band));
bandEnergy_[b] = bandEnergy_[b] * 0.78f + rms * 0.22f;
}
} else {
// Synthetic when scope is silent : drive bands by voice amps
const float t = ofGetElapsedTimef();
const float bands[] = {kick_, bass_, lead_, pad_};
for (std::size_t b = 0; b < bandEnergy_.size(); ++b) {
float a = bands[b % 4];
float syn = a * (0.5f + 0.5f * std::sin(t * (2.0f + b * 0.3f) + b));
bandEnergy_[b] = bandEnergy_[b] * 0.85f + syn * 0.15f;
}
}
// Build deformed sphere
const auto& base = sphereBase_.getVertices();
auto& def = sphereDeformed_.getVertices();
if (def.size() != base.size()) sphereDeformed_ = sphereBase_;
for (std::size_t i = 0; i < base.size(); ++i) {
const glm::vec3& v = base[i];
// Use the vertex position to pick a band (stable hash)
float pick = std::abs(v.x * 11.7f + v.y * 5.3f + v.z * 2.1f);
std::size_t b = static_cast<std::size_t>(std::floor(pick * bandEnergy_.size()))
% bandEnergy_.size();
float displace = 1.0f
+ bandEnergy_[b] * 1.6f
+ (frame.osc.beatPulse() ? 0.0f : 0.0f);
def[i] = v * displace * pulseScale_;
}
}
void MeshVis::draw(int x, int y, int w, int h) {
ofPushStyle();
ofPushMatrix();
// Restrict camera to this rectangle by translating the whole scene
// so its center is at (x+w/2, y+h/2). ofCamera uses the global
// window for its perspective matrix; we draw inside a viewport.
glEnable(GL_DEPTH_TEST);
glClear(GL_DEPTH_BUFFER_BIT);
cam_.begin(ofRectangle(x, y, w, h));
// Subtle background gradient
ofDisableDepthTest();
ofPushStyle();
ofSetColor(8, 12, 30);
ofDrawRectangle(-50, -50, 100, 100);
ofPopStyle();
ofEnableDepthTest();
ofPushMatrix();
ofRotateRad(yaw_, 0, 1, 0);
ofRotateRad(pitch_, 1, 0, 0);
// Wireframe back-pass — slightly larger, dim
ofSetLineWidth(1);
ofSetColor(60, 90, 160, 200);
sphereDeformed_.drawWireframe();
// Solid pass (additive over background)
ofEnableBlendMode(OF_BLENDMODE_ADD);
const float baseHue = std::fmod(yaw_ * 30.0f + bass_ * 80.0f, 360.0f);
ofSetColor(ofColor::fromHsb(baseHue, 200, 200, 130));
sphereDeformed_.draw();
// Inner glow by drawing the wireframe again at smaller scale, brighter
ofPushMatrix();
ofScale(0.85f);
ofSetColor(255, 220, 180, 180);
sphereBase_.drawWireframe();
ofPopMatrix();
ofDisableBlendMode();
ofPopMatrix();
cam_.end();
glDisable(GL_DEPTH_TEST);
ofPopMatrix();
ofPopStyle();
}
} // namespace oscope
+31
View File
@@ -0,0 +1,31 @@
#pragma once
// 3D mesh visualizer : icosphere whose vertices are pushed in/out by a
// 32-band approximation of the channel waveform (cheap RMS per region,
// not a real FFT — we want look, not measurement). Rotates on its own
// and gets a kick on each beat. Uses ofCamera for proper perspective.
#include "Visualizer.h"
#include "ofMain.h"
namespace oscope {
class MeshVis : public Visualizer {
public:
void setup(int w, int h) override;
void update(const VisFrame& frame) override;
void draw(int x, int y, int w, int h) override;
private:
int w_ = 0, h_ = 0;
ofMesh sphereBase_;
ofMesh sphereDeformed_;
ofCamera cam_;
float yaw_ = 0.0f, pitch_ = 0.0f;
float pulseScale_ = 1.0f;
float bass_ = 0.0f, lead_ = 0.0f, kick_ = 0.0f, pad_ = 0.0f;
float bpm_ = 120.0f;
std::vector<float> bandEnergy_;
};
} // namespace oscope
+41
View File
@@ -0,0 +1,41 @@
#include "TunnelVis.h"
namespace oscope {
void TunnelVis::setup(int w, int h) { w_ = w; h_ = h; reloadShaders(); }
void TunnelVis::reloadShaders() { shader_.load("shaders/tunnel"); }
void TunnelVis::update(const VisFrame& frame) {
bpm_ = frame.osc.bpm();
kick_ = frame.osc.amp("kick");
bass_ = frame.osc.amp("bass");
lead_ = frame.osc.amp("lead");
pad_ = frame.osc.amp("pad");
travel_ += static_cast<float>(ofGetLastFrameTime()) * (0.6f + bpm_ * 0.012f + kick_ * 1.5f);
}
void TunnelVis::draw(int x, int y, int w, int h) {
if (!shader_.isLoaded()) {
ofPushStyle();
ofSetColor(40);
ofDrawRectangle(x, y, w, h);
ofSetColor(255, 80, 80);
ofDrawBitmapString("tunnel shader missing", x + 12, y + 24);
ofPopStyle();
return;
}
shader_.begin();
shader_.setUniform2f("uRes", static_cast<float>(w), static_cast<float>(h));
shader_.setUniform1f("uTime", ofGetElapsedTimef());
shader_.setUniform1f("uTravel", travel_);
shader_.setUniform1f("uBpm", bpm_);
shader_.setUniform1f("uKick", kick_);
shader_.setUniform1f("uBass", bass_);
shader_.setUniform1f("uLead", lead_);
shader_.setUniform1f("uPad", pad_);
ofDrawRectangle(x, y, w, h);
shader_.end();
}
} // namespace oscope
+26
View File
@@ -0,0 +1,26 @@
#pragma once
// Cylindrical raymarched tunnel — fullscreen fragment shader. Speed
// follows BPM, twist follows lead amplitude, segment lighting pulses
// on kick and bass.
#include "Visualizer.h"
#include "ofMain.h"
namespace oscope {
class TunnelVis : public Visualizer {
public:
void setup(int w, int h) override;
void update(const VisFrame& frame) override;
void draw(int x, int y, int w, int h) override;
void reloadShaders() override;
private:
int w_ = 0, h_ = 0;
float travel_ = 0.0f;
float bpm_ = 120.0f, kick_ = 0.0f, bass_ = 0.0f, lead_ = 0.0f, pad_ = 0.0f;
ofShader shader_;
};
} // namespace oscope