QTVR ShockWave3D

©2002 Joachim Baur, Medienwerkstatt Schorndorf

 

Eine kurze Inhaltsübersicht:

      Zylindrische QTVRs mit Shockwave3D-#cylinder primitive:

      Kubische QTVRs mit Shockwave3D-#box primitive:

 

Hier gleich die Shockwave 8.5-Version für den Browser:

.dcr zylindrisch QTVR-Cylinder.htm (160 KB)
.dcr kubisch QTVR-Box.htm (110 KB)

 

Und hier die ungeschützten Director-Movies dazu:

.dir zylindrisch Macintosh Version (.sit-Archiv, 1,3 MB)
Windows Version (.zip-Archiv, 1,5 MB)

 

.dir kubisch

 

Macintosh Version (.sit-Archiv, 580 KB)

Windows Version (.zip-Archiv, 620 KB)

 

 

1. Einleitung - Zylindrische und Kubische QTVRs

Das Ausgangsmaterial ("SourcePict(s)") eines QTVRs ist die Abbildung eines 3-dimensionalen Raums, entweder real fotografiert und dann mit "Stitching"-Software zusammengerechnet oder direkt aus einem 3D-Programm gerendered. Dieses Ausgangsbild zeigt möglichst den gesamten Raum in einer zylindrischen oder kubischen Abwicklung. Die VR-Player-Software (traditionell Apples eigener QuickTimePlayer, es gibt aber inzwischen auch reine JavaPlayer und andere Abspielprogramme) stellt diese Abwicklung wieder perspektivisch richtig und frei navigierbar dar.

Zylindrisches QTVR Kubisches QTVR
• QuickTime-Version <= 5 zur Darstellung nötig • QuickTime-Version >= 5 zur Darstellung nötig

• horizontal (links/rechts): 360°-Drehung möglich
• vertikal (rauf/runter): nur begrenztes Sichtfeld

• horizontal (links/rechts): 360°-Drehung möglich
• vertikal (rauf/runter): ebenfalls 360°-Drehung möglich
• 1 SourcePict für die Zylinder-Innenseite: • 6 SourcePicts für die Würfel-Innenseiten:
oben: SourcePict, darunter: Projektion innen auf Zylinder oben: SourcePicts, darunter: Projektion innen auf Würfel

Solange der Betrachter genau in der Mitte des imaginären "QTVR-Körpers" (Zylinders bzw. Würfels) steht und nur den Kopf dreht bzw. neigt, sieht das auf die Innenseite des Körpers projizierte Bild für ihn - perspektivisch korrekt - wie der ursprünglich aufgenommene dreidimensionale Raum aus.

Das für reguläre QuickTimeVRs erstellte Source-Material soll nun (ohne weitere Bearbeitung) in Director importiert und (möglichst einfach) mit ShockWave3D-Primitives dargestellt, navigiert und animiert werden - inkl. der Hotspots, das sind beliebig geformte Bereiche im QTVR-Raums, durch die eine Interaktion des Benutzers mit Gegenständen/Objekten im Raum möglich wird (z.B. per MouseOver oder MouseDown).

Im Prinzip ist dies nichts grundlegend Neues - solche QTVR-Simulationen waren schon seit der Einführung von Quads in Director 7 möglich, für zylindrische QTVRs beispielsweise mit der QTVRLingoEngine und für kubische QTVRs mit Nonoches klassischem ShockTimeVR.

Neu sind nur einige zusätzliche Möglichkeiten, dass z.B. beliebige andere Shockwave3D-Modelle in diese Shockwave3D-QTVRs mit integriert werden können. Und für mich selbst war dieses Projekt ein erster Einstieg in die 3D-Programmierung unter Director 8.5. Auf dieser Basis wurde deshalb auch dieser Vortrag für das DirectorList-Usertreffen in Frankfurt Anfang März 2002 zusammengestellt.

 

2. Initialisierung des Behaviors und der 3D-Welt

Es gibt zwei große Unterschiede im Vergleich von Sprites traditioneller Medientypen (Text, Bitmap, Video, Flash, etc...) zu einem Shockwave3D-Sprite:

Deswegen ist es zwingend notwendig, im Authoring Modus am Anfang ("on beginSprite") des 3D-Welt-Sprites dessen Member manuell zurückzusetzen, da sonst andauernd Script- und Laufzeitfehler auftauchen (es dürfen z.B. keine 2 Objekte in der 3D-Welt denselben Namen tragen, was bei einem zweiten Start desselben Director-Films sonst zwangsläufig passiert). Erst wenn Director bzw. der Director-Film komplett beendet und wieder geöffnet wurde, ist auch der 3D-Welt-Member wieder zurückgesetzt (=leer, wie das Script ihn auch erwartet).

Anmerkung: Manchmal (nach vielem Herumspielen im 3D-Member) hilft selbst das nicht, und der Ausgangszustand des 3D-Members ist anders als eigentlich vom Script erwartet (die Kamera zeigt in eine andere Richtung, etc) - bevor man also verzweifelt versucht, die geänderten Einstellungen irgendwie zu finden/per Script zu korrigieren (an dieser Stelle empfehle ich DRINGEND die Lizensierung von Ullalas 3DPI), hilft es mir oft, den Member komplett zu löschen und einen neuen 3D-Member gleich wieder an derselben Stelle im Cast anzulegen.
Mit Alex3-DTool kann man den 3D-Member-Zustand auch zur Laufzeit speichern und später wieder in diesen Zustand zurückversetzen.

Mein "beginSprite"-Handler sieht deshalb zu Anfang so aus:

global gBehavRef, gMy3Dworld
property pMySprite, pMyOrigin

on beginSprite me

  gBehavRef = me

  pMySprite = sprite(me.spritenum)
  pMyOrigin = point(pMySprite.left, pMySprite.top)

  gMy3Dworld = pMySprite.member
  gMy3Dworld.resetWorld()
  gMy3Dworld.revertToWorldDefaults()

end

ResetWorld() und revertToWorldDefaults() setzen den 3D-Member gMy3Dworld zurück,
in der globalen Variablen
gBehavRef wird zusätzlich eine Referenz auf die zur Laufzeit generierte Behavior-Instanz dieses Scripts abgelegt, um das Debuggen zu erleichtern - im Message-Fenster kann dann z.B. mit "put gBehavRef.pMySprite" jede property des Behaviors abgefragt oder gesetzt werden, genauso im Watcher.

 

3. Anlegen von Model & Resource

In die noch leere 3D-Welt soll nun zuerst ein Zylinder gestellt werden.

Grundsätzlich müssen für jedes 3D-Model vier Eigenschaften definiert werden:

Für das zylindrische QTVR brauchen wir ein Model mit der Model-Resource "Zylinder", auf dessen Oberfläche die "SourcePict"-Textur mit einem möglichst neutralen/gleichmäßigen Shader abgebildet wird.

Es ist dabei gar nicht wichtig, wie groß der Zylinder ist (gemessen wird in 3D-World-Units) - sobald das Verhältnis von Radius zu Höhe stimmt, wird die SourcePict-Textur nämlich automatisch perspektivisch richtig dargestellt. Der im Sprite sichtbare (Kamera-)bereich ist nämlich genau derselbe, ob der Zylinder nun einen Radius von 10 oder von 10.000 World-Units hat (ausprobieren, wer’s nicht glaubt ;-)

Die Zylinder-Abmessungen

Um das richtige Verhältnis von Zylinder-Radius:Höhe zu ermitteln, wird der Einfachheit halber die Zylinderhöhe gleich der Höhe des SourcePict-Bitmaps gesetzt. Der Zylinderradius ist dann gleich der Breite des SourcePicts geteilt durch 2*Pi (KreisRadius = KreisUmfang/2*Pi).

Der Lingocode für die Generierung des Zylinder-Models und seiner Resource:

property pMyQTVRmodel, pMyQTVRresource

on beginSprite me
  ...

  pMyQTVRmodel = gMy3Dworld.newModel("QTVRmodel")

  cylinderHeight = member("SourcePict").height
  radius = member("SourcePict").width/(2*PI)

  pMyQTVRresource = gMy3Dworld.newModelResource("QTVRcylResource", #cylinder, #back)

  pMyQTVRresource.topRadius = radius
  pMyQTVRresource.topcap = false
  pMyQTVRresource.bottomRadius = radius
  pMyQTVRresource.bottomcap = false

  pMyQTVRresource.height = cylinderHeight

  pMyQTVRresource.resolution = 72.0

  pMyQTVRmodel.resource = pMyQTVRresource
  ...
end

Der Befehl newModelResource legt eine neue Model-Resource an, vom Typ #cylinder - das ist eine bereits vordefinierte Gestalt in ShockWave3D, ein sogenanntes "primitve", dem wir nur noch die gewünschten Abmessungen mitteilen müssen.
Der Parameter #back bedeutet, dass nur die Innenseite dieses Zylinders existieren soll – die Kamera steht sowieso im Zylinder drin, eine Aussenseite brauchen wir also eh nicht zu definieren.

Die Angaben topcap = false und bottomcap = false lassen auch noch die beiden Deckel-Flächen weg, so dass ein oben und unten offenes Rohr entsteht. Die Angabe resolution = 72.0 verfeinert schließlich die Darstellung der Rohrrundung (der Defaultwert ist 20) – statt in 20 ist das Rohr jetzt in 72 Abschnitte entlang des Umfangs untergeteilt, im Grundriss also eigentlich ein geglättetes 72-Eck.

Solange nur das Model selbst definiert ist (pMyQTVRmodel = gMy3Dworld.newModel("QTVRmodel")), ist in der 3D-Welt noch nichts zu sehen. Nach der Zuweisung der Resource (pMyQTVRmodel.resource = pMyQTVRresource) stellt Director dann das Zylinder-Model mit einer Default-Textur (dem rot-weissem Schachbrettmuster), einem Default-Shader und einer Default-Lichtquelle dar:

Default-Textur und -Shader

Der nächste Schritt ist es daher, diese Default-Textur/-Shader durch unsere eigenen zu ersetzen.

 

4. Anlegen von Textur & Shader

Die Abmessungen einer Textur (in Pixeln) sollten für eine optimale Rendering-Perfomance jeweils eine Potenz von 2 sein - also z.B. 64x64, 128x32, 256, 512, ... etc. Grundsätzlich kann man aber mit jeder beliebigen Texturgröße arbeiten - die "SourcePict"-Textur in meinem Beispielfilm hat z.B. eine Größe von 1440 x 440 Pixeln.
Diese Textur wird nahtlos auf die Innenfläche unseres Zylinders "aufgemalt". Der Beginn der Textur (= linke Kante des SourcePicts, Pixel-X-Koordinate 0) liegt dabei auf der positiven Z-Achse bei X=0, die Projektion erfolgt im Uhrzeigersinn (von oben gesehen) entlang des Zylinderumfangs:

Anmerkung: Das Weltkoordinatensystem (rechts grau gezeichnet) von Director sieht so aus, dass die Z-Achse nach vorne zeigt. Dieses Koordinatensystem ist einmal für die Welt selbst vorhanden (als Referenzsystem, kann auch nicht bewegt werden). Jedes Model in der 3D-Welt hat aber nochmal sein eigenes Koordinatensystem (rot), das mit dem Objekt selbst gedreht/verschoben wird, so daß es für das weitere Drehen/Verschieben sehr wichtig ist, welches der Koordinatensysteme man als Bezugspunkt angibt (#self oder #world).

Die Textur kann dem Model nicht direkt zugewiesen werden, dazu ist ein Shader nötig. Für jede im Model vorkommende Fläche kann/muss ein eigener Shader vorhanden sein. Die Eigenschaften des Shaders repräsentieren die Material-Eigenschaften, die die jeweilige Fläche haben soll, ob sie Licht reflektiert, transparent ist, etc.
Als genereller Shadertyp kommt für das QTVR nur der #standard-Shader in Frage, der die Texturen photorealistisch rendered. Die anderen Typen erzeugen mehr oder weniger brauchbare stilisierte Texturzeichnungen (verwendet irgend jemand den #engraver-shader? IRGEND JEMAND?? ;-).

Textur und Shader werden folgendermaßen im Behavior initialisiert:

property pMyQTVRtexture, pMyQTVRshader

on beginSprite me
  ...
  pMyQTVRtexture = gMy3Dworld.newTexture("QTVRcylTexture", #fromCastMember, member("SourcePict"))

  pMyQTVRshader = gMy3Dworld.newShader("QTVRcylShader", #standard)

  pMyQTVRshader.emissive = rgb(255, 255, 255)
  pMyQTVRshader.ambient = rgb(0,0,0)
  pMyQTVRshader.flat = true

  pMyQTVRshader.texture = pMyQTVRtexture
  pMyQTVRmodel.shader = pMyQTVRshader
 ...
end

Die Shader-property emissive sorgt dafür, dass die Zylinderfläche mit einer weissen Lichtfarbe (rgb(255,255,255)) quasi aus sich selbst heraus leuchtet (ohne aber eine Lichtquelle zu sein!). Das muss man sich wie ein Dia vorstellen, dass auf einem Leuchttisch von hinten mit weissem Licht angestrahlt wird. Auf diese Weise wird der ganze Zylinder gleichmäßig ausgeleuchtet, die Default- und andere Lichtquellen werfen keine Schatten mehr in auf meine Raumtextur.

Die ambient-Lichtfarbe wird entsprechend auf schwarz (rgb(0,0,0)) gesetzt, um auch keine Aufhellungen der Raumtextur durch externe Lichtquellen zuzulassen.

Die property flat=false ist nicht unbedingt nötig, beschleunigt aber die Darstellung in soweit, dass die aufwändigere Methode (flat=true, Defaulteinstellung) zur Licht-/Schattenberechnung gar nicht erst zum Einsatz kommt.

Nach dieser Zuweisung von Textur und Shader wird der Raum schon richtig dargestellt, einzig die Perspektive (der Standpunkt unserer Kamera) stimmt noch nicht:

Richtige Textur/Shader-Darstellung

 

5. Navigation im QTVR (durch Kamerabewegung)

Damit wir uns in unserem Raum-Zylinder umschauen können (navigieren), ist es am einfachsten, die Kamera zu bewegen (drehen/neigen) und den Zylinder fest an seinem Platz stehen zu lassen.
Zunächst muss die Kamera aber an die richtige Stelle platziert werden, nämlich ins Zentrum unseres Zylinders und damit auch gleichzeitig ins Zentrum der 3D-Welt: An die Stelle X=0, Y=0, Z=0:

property pMyCamera

on beginSprite me
  ...
  gMy3Dworld.cameraPosition = vector(0.0000, 0.0000, 0.0000)
  pMyCamera = gMy3Dworld.camera[1]
  ...
end

Damit werfen wir den ersten Blick ins perspektivisch korrekt dargestellte Wohnzimmer:

Startposition der Kamera

Die Kamerabewegung ist in 3 Komponenten unterteilt:

Alle drei Kamerabewegungen können einzeln oder miteinander kombiniert ausgeführt werden.

Auslöser (Trigger) für diese Aktionen können sein:

Um alle Trigger-Möglichkeiten jederzeit mit allen Kamera-Aktionen kombinieren zu können, verwende ich für die QTVR-Steuerung selbstdefinierte "Flags". Diese Flags sind globale Variablen, die durch die Trigger-Aktion gesetzt (=mit einem Wert <>0 versehen) oder wieder gelöscht (=0 gesetzt) werden. Der "exitFrame"-Handler des QTVR-Behaviors prüft dann alle diese Flags der Reihe nach und rotiert/dreht/zoomt die Kamera entsprechend. So werden auch Performance-Killer wie repeat-Schleifen vermieden, da z.B. bei MouseDown das "startDrag"-Flag gesetzt und bei MouseUp wieder gelöscht wird - es ist keine "repeat while the mouseDown"-Schleife nötig.

Die Flags habe ich wie folgt benannt:

 

5.1 Panning

globalgPanFlag

on beginSprite me
  ...
  gPanFlag = 0
  ...
end

on exitFrame me
 ...
  case true of
    (keypressed(123)): -- Cursor-Links-Taste
      gPanFlag = 1
    (keypressed(124)): -- Cursor-Rechts-Taste
      gPanFlag = -1
    otherwise gPanFlag = 0
  end case

   ...
  if gPanFlag then
    pMyCamera.rotate(0, 5*gPanFlag, 0, #world)
  end if
  ...
end

Sobald also eine der Cursor-Links/Rechts-Tasten gedrückt ist, wird gPanFlag entsprechend gesetzt.
Weiter unten im Script wird gPanFlag dann abgefragt und die Kamera um 5 Grad rotiert, und zwar um die WELT-Y-Achse.

Pan-Prinzip der Kamera

5.2 Tilting

globalgTiltFlag

on beginSprite me
  ...
  gTiltFlag = 0
  ...
end

on exitFrame me
 ...
  case true of
    (keypressed(125)): -- Cursor-Runter-Taste
      gTiltFlag = -1
    (keypressed(126)): -- Cursor-Hoch-Taste
      gTiltFlag = 1
    otherwise gTiltFlag = 0
  end case

   ...
  if gTiltFlag then
    pMyCamera.rotate(4*gTiltFlag, 0, 0, #self)
  end if
  ...
end

Diesmal wird die Kamera um 4 Grad gekippt (rotiert), und zwar um die MODEL-Y-Achse.
Ein kippen um die WELT-Y-Achse würde nicht funktionieren, wie diese Zeichnung zeigt:

Tilt-Prinzip der Kamera

Hier ist die Kamera bei der Anwendung der "Tilt"-Drehung schon (per "pan") um 90 Grad um die WELT-Y-Achse gedreht.
Die Modell-X-Achse (grün) liegt jetzt auf der Welt-Z-Achse (grau), und umgekehrt entspricht die Welt-X-Achse der Model-Z-Achse.
Würde jetzt die Kamera-"Tilt"-Rotation auf die WELT-X-Achse angewandt, dreht sich die Kamera fälschlicherweise um ihre eigene Z-Achse:

nicht rotiert um X-Achse rotiert mit #world rotiert mit #self
Kamera mit 90 Grad "Pan"... rotate(4,0,0,#world) > FALSCH! rotate(4,0,0,#self) > RICHTIG!

Das richtige Bezugssystem (#self <> #world) zu wählen ist also entscheidend – aber nicht immer gleich offensichtlich...

Ein weiteres Problem ist durch die Natur unseres Zylinders zwangsläufig gegeben: Er ist oben und unten offen, und wenn die Kamera immer weiter geneigt wird, sieht man irgendwann auch den oberen/unteren Rand und die große leere schwarze 3D-Welt "draussen":

Obere Kante sichtbar

Es muss also ein Grenzwert für den Tilt-Rotationswinkel gefunden werden, so dass der sichtbare Kamerabereich immer innerhalb des Zylinders bleibt. Dieser Grenzwert hängt davon ab, wie hoch das jeweilige SourcePict ist.
Der maximal sichtbare Bereich (maxFoV) lässt sich folgendermassen ausrechnen:

Berechnung des maxFoV

Umgesetzt in Lingo:

property pMaxFov

on beginSprite me
  ...
  
maxFovRad = 2*atan((cylinderHeight/2)/radius)
  -- Rad zu Grad umrechenen: Grad = 360 * Rad / 2*PI
  pMaxFov = 180*maxFovRadians/PI
  ...
end

Innerhalb dieses Winkelbereichs pMaxFov kann jetzt die Kamera nach oben und unten gekippt werden – allerdings muss von pMaxFov zuerst noch der aktuelle Blickwinkel der Kamera abgezogen werden (camera.fieldOfView), dann erhält man den "remainingTilt"-Winkel nach oben/unten:

Die Tilt-Funktion im Behavior wird deswegen wie folgt erweitert:

property pMaxFov, pRemainingTilt, pTotalTilt

on exitFrame me
  ...
  
if gTiltFlag then
    newTilt = pTotalTilt + 4*gTiltFlag
    if abs(newTilt) < pRemainingTilt then
      pMyCamera.rotate(4*gTiltFlag, 0, 0, #self)
      pTotalTilt = newTilt
    end if
  end if
  ...
end

Dabei wird in pTotalTilt der aktuelle Tilt-Winkel der Kamera gespeichert und vor dem tatsächlichen Rotieren der Kamera wird erst geprüft, ob der newTilt noch innerhalb des erlaubten Bereichs liegt, erst dann wird die Kamera gedreht und pTotalTilt aktualisiert.

5.3 Zooming

globalgZoomFlag

on beginSprite me
  ...
  gZoomFlag = 0
  ...
end

on exitFrame me
  ...
  case true of
    (the shiftDown):
      gZoomFlag = -1
    (the controlDown):
      gZoomFlag = 1
    otherwise gZoomFlag = 0
  end case
  ...
  if gZoomFlag then
    newFov = pMyCamera.fieldOfView + 2*gZoomFlag

    if newFov > pMinFoV and newFov < pMaxFoV then

      pRemainingTilt = (pMaxFoV - newFov)/2
      pMyCamera.fieldOfView = newFov

    end if
  end if
  ...
end

Um ein- und auszuzoomen, wird nicht die Position der Kamera verändert – diese muss immer in der Mitte des Zylinders stehen, um die räumlich korrekte Pespektive zu erhalten – sondern nur der Blickwinkel der Kamera von einem "Weitwinkelobjektiv" (fieldOfView <= pMaxFov) auf ein Teleobjektiv (fieldOfView >= pMinFov, wobei pMinFov willkürlich gewählt ist, aber > 0.0 sein muss).

Auch beim Zoomen gibt es eine zusätzliche Schwierigkeit, die mit dem oberen/unteren Rand des Zylinders zusammenhängt:
Wenn die Kamera bei einem relativ geringen fieldOfView (= nah herangezoomt) ihren maximalen Kippwinkel erreicht hat und der User herauszoomt, ist wieder die Kante des Zylinders zu sehen, sofern der Kippwinkel (Tilt) beim herauszoomen nicht dynamisch mit angeglichen wird:

 

camera.fieldofview vergrößert,
TiltWinkel gleich geblieben

camera.fieldofview vergrößert,
TiltWinkel angepaßt

Die Zoomfunktion im Behavior wird deshalb wie folgt erweitert:

on exitFrame me
  ...
  if newFov > pMinFoV and newFov < pMaxFoV then
    pRemainingTilt = (pMaxFoV - newFov)/2
    if pTotalTilt < 0 then
      if abs(pTotalTilt) > pRemainingTilt then
        newTilt = abs(pTotalTilt) - pRemainingTilt
        pMyCamera.rotate(newTilt, 0, 0)
        pTotalTilt = -pRemainingTilt
      end if
    else
      if pTotalTilt > pRemainingTilt then
        newTilt = pRemainingTilt - pTotalTilt
        pMyCamera.rotate(newTilt, 0, 0)
        pTotalTilt = pRemainingTilt
      end if
    end if
    pMyCamera.fieldOfView = newFov
  end if
  ...
end

Dabei wird vor der fieldOfView-Änderung der Kamera kontrolliert, ob der dadurch neu pRemainingTilt eine Anpassung des pTotalTilt nötig macht. Falls dies der Fall ist, wird die Anpassung sofort vorgenommen (pMyCamera.rotate(newTilt,0,0)) und dann erst der neue camera.fieldofview gesetzt.

Damit ist die Navigation im zylindrischen QTVR voll funktionsfähig, der Beispiel-Directorfilm zum download enthält zusätzlich zu der hier beschriebenen Tastatursteuerung auch noch die Maus-Navigationsfunktionen ("click&drag").

 

6. Hotspot-Erkennung und -Interaktion

Um mit einem QTVR interagieren zu können, werden sogenannte "Hotspots" definiert, vergleichbar zum Beispiel den frei definierbaren Klickbereichen einer ImageMap auf HTML-Seiten.

Um die Hotspot-Areas zu definieren, verwendet Apples QTVR ein Hotspot-Bitmap (im Bild unten), das genau gleich groß ist wie das SourcePict des Panoramas (im Bild darüber). Um Speicherplatz zu sparen, wird das Hotspot-Bitmap nur in einer Farbtiefe von 8 Bit (traditionell mit der System-Mac-Farbpalette) angelegt. Andere Paletten (und selbst 24Bit-Bitmaps) können aber auch problemlos verwendet werden.
Innerhalb dieses weissen Bitmaps werden die Hotspots mit jeweils separaten Palettenfarben "ausgemalt".

Achtung:

 

Die Hotspot-Detection im Behavior erfolgt in drei Teilschritten:

  1. Über welchem Punkt (dx, dx, dz) des Zylinders befindet sich der Cursor?

  2. Welchem Pixel (x,y) der Hotspot-Bitmap entspricht dieser Punkt (dx, dy, dz)?

  3. Welchen Farbwert hat der Pixel (x,y) der Hotspot-Bitmap?

Für Punkt 1 kann der 3D-Lingo-Funktion modelsUnderLoc() verwendet werden. Dieser Funktion wird ein point(x,y) innerhalb des SpriteRects übergeben, der Rückgabewert ist eine lange Liste teils nützlicher, teils verwirrender Parameter:

put modelUnderLoc(testLoc, 1, #detailed)

-- [[#model: model("QTVRmodel"), ¬
  #distance: 26.8831, ¬
  #isectPosition: vector( -25.0000, -0.4623, -9.8736 ), ¬
  #isectNormal: vector( 1.0000, 0.0000, 0.0000 ), ¬
  #meshID: 2, ¬
  #faceID: 1, ¬
  #vertices: [vector(-25.000 , 25.000 , -25.0000 ), ¬
              vector( -25.0000, 25.0000, 25.0000 ), ¬
              vector( -25.0000, -25.0000, -25.0000 )], ¬
  #uvCoord: [#u: 0.3025, #v: 0.5092]]]

Von diesen Daten ist die #isectPosition am interessantesten: Das sind die räumlichen Koordinaten dx,dy,dz des Punkts auf der Zylinderfläche, der im Beispiel oben dem testLoc-Punkt innerhalb des SpriteRects entspricht. Da die genaue Position und die Abmessungen des Zylinders bekannt sind, kann damit der gesuchte Pixel (x,y) der Zylindertextur und somit entsprechend im Hotspot-Bitmap bestimmt werden.

Anmerkung: Auch aus der #uvCoord-Liste kann dieser Punkt bestimmt werden (Dank an Alex da Franca für den Tipp mit dem #meshdeform bei primitives!). Die uvCoords sind relative Texturkoordinaten-Anteile bezogen auf das aktuelle Mesh, aus denen wieder die absoluten Texturkoordinaten berechnet werden können. Das zugrundeliegende Prinzip habe ich allerdings nicht verstanden, deswegen verwende ich die #isectPosition. Bei einem kleinen Benchmark war die Berechnung des Texturpixels über die #isectPosition zudem 2-3 mal schneller als über die #uvCoord – und die Berechnung muss ja bei jedem exitFrame durchgeführt werden, solange der Cursor über dem 3D-Welt-Sprite ist.
Der Lingo-Code für die #uvCoord -Berechnung ist bei directordev.com zu haben, für alle, die es selbst testen wollen! Ich hatte natürlich diesen Code erst für mein Projekt optimiert, trotzdem sind die Berechnungen an sich einfach zeitaufwändiger.
iSect-Weltkoordinaten
Die iSectPosition besteht aus den drei
Koordinaten-Anteilen dx, dy und dz.

Am einfachsten ist der Y-Wert des Bitmappixels zu berechnen: Der Zylinder wurde ja mit der Höhe des SourcePicts in Pixeln angelegt, der Y-Wert kann daher direkt aus dem dy-Wert bestimmt werden:

Bestimmung von Y (Textur)

Etwas komplizierter wird es mit dem X-Wert des Bitmappixels. Da die Textur der Breite nach um den Zylinder gewickelt ist, muss zuerst der Winkel der #isectPositon-Anteile (dx, dz) zum Ursprung der Textur gefunden werden:

Bestimmung von X (Textur) dx und dz in der Draufsicht angleBetween()-Funktion
Die Textur ist von 0 Grad im Uhrzeigersinn
(Draufsicht) um den Zylinder gewickelt
Ursprung der Textur: vector(0, 0, 1)
isectPositions-vector: vector(dx, 0, dz)
angleBetween() liefert Werte von
0 bis 180 Grad für beide Kreishälften.

Danke an Ullala für den Tipp mit angleBetween()! Ich hatte das zuerst umständlicher mit der atan()-Funktion gemacht

Die Winkelwerte von jeweils 0...180 Grad müssen nur noch zu 0...360 Grad umgerechnet werden, dann kann der x-Wert des Texturpixels ebenfalls bestimmt werden. Alles zusammen sieht dann so aus:

on beginSprite
...
gHotSpotImage = member("HotspotBitmap").image

on exitFrame
...
iSectData = pMyCamera.modelsUnderLoc(the mouseLoc-pMyOrigin, 1, #detailed)
iSectWorldCoord = iSectData[1].isectPosition
dx = iSectWorldCoord[1]
dy = iSectWorldCoord[2]
dz = iSectWorldCoord[3]

y = pTextureHeight/2 - dy

x = vector(0,0,-1).angleBetween(vector(dx,0,dz))

if dx > 0 then
  x = (x + 180) * pScaleFactor
else
  x = (180 - x) * pScaleFactor
end if

newHotSpotNum = gHotSpotImage.getPixel(x, y, #integer)

Solange der Cursor sich über dem Sprite befindet, wird die aktuelle mouseLoc abzüglich der linken oberen Ecke des Sprites (in der property pMyOrigin gespeichert) getestet, x und y berechnet und per getPixel der Farbwert des Hotspot-Pixels ausgelesen.

Darauf aufbauend können dann die eigenlichen Hotspot-Interaktionen und -Animationen progrmmiert werden – die Cursor-Änderung beim Rollover, Aktionen beim Anklicken oder Verlassen des Hotspots, etc.

 

7. Animationsmöglichkeiten

Dadurch, dass das QTVR in eine ShockWave3D-Welt integriert ist, können innerhalb des QTVRs Effekte und Animationen stattfinden, die unter Einbindung eines traditionellen Apple-QTVR-movs in einen Director-Film überhaupt nicht möglich wären.

Drei verschiedene Animationsmöglichkeiten stehen jetzt zur Verfügung:

  1. Veränderungen bei den 3D-Model-Eigenschaften

  2. Veränderungen in der Texturbitmap per Imaging Lingo

  3. Zusätzliche 3D-Modelle in der 3D-Welt

Als Beispiel für eine Animation/Veränderung der 3D-Model-Eigenschaften des QTVRs wird hier die Shader-Property "emissive" von der ursprünglich weissen Lichtfarbe auf einen Blauton geändert:

shader.emissive = rgb(255,255,255)
-- weisse Lichtfarbe
shader.emissive = rgb(102,102,204)
-- blaue Lichtfarbe

Als Resultat ist der Raum nur noch schwach bläulich ausgeleuchtet - ein typischer "Nachtaufnahmen"-Look, bekannt aus Film und Fernsehen.

Eingriffe in die Texturbitmap selbst per Imaging Lingo können den ganzen Raum "umgestalten".
Dazu muss die Textur des QTVR-Shaders über die #fromImageObject-Methode anstatt #fromCastMember definiert werden:

global gQTVRimage

on beginSprite me
  ...
  gQTVRimage = member("SourcePict").image.duplicate()
  pMyQTVRtexture = gMy3Dworld.newTexture("QTVRcylTexture", #fromImageObject, gQTVRimage)
  ...
end

Das Image-Object wird hier in einer globalen Variablen gQTVRimage aufbewahrt, so dass auch Behavior-externe Scripte einfach darauf zugreifen können. Damit das ursprüngliche Image-Bitmap des SourcePicts erhalten bleibt (um Änderungen per Imaging Lingo auch wieder rückgängig machen zu können), wird nicht das Image des Castmembers referenziert, sondern eine Kopie des Image im Speicher erzeugt (image.duplicate())

Als Anwendungsbeispiel soll die Schreibtisch-Lampe per Klick aus- und auch wieder eingeschaltet werden können. Dazu ist zusätzlich zum SourcePict mit der eingeschalteten Lampe ein Bitmap-Ausschnitt mit der ausgeschalteten Lampe nötig:

Dieses Bitmap (Castmember "LampeAus") wird jetzt bei dem ersten Klick auf den Lampen-Hotspot in das gQTVRimage hineinkopiert, beim zweiten Klick wird der entsprechende Ausschnitt aus dem Original-Bitmap von Castmember "SourcePict" wieder hergestellt:

case pCurrHotSpot of
  -- Hotspot 32 = Schreibtisch-Lampe
  32:
    if pDeskLampStatus = 1 then
      -- Schreibtisch-Lampe ausschalten
      gQTVRimage.copyPixels(member("LampeAus").image, rect(1109,256,1238,337), rect(1,1,129,81))
      pMyQTVRtexture.image = gQTVRimage
      pDeskLampStatus = 0
    else
      -- Schreibtisch-Lampe anschalten
      gQTVRimage.copyPixels(member("SourcePict").image, rect(1109,256,1238,337),rect(1109,256,1238,337))
      pMyQTVRtexture.image = gQTVRimage
      pDeskLampStatus = 1
    end if
end case

nachdem das Image-Object mit copyPixels verändert wurde, muss es der Textur wieder explizit zugewiesen werden (pMyQTVRtexture.image = gQTVRimage), damit die Änderung in die 3D-Welt übernommen wird.

Zusätzliche zu diesem zweidimensionalen Animationen können beliebige weitere 3D-Modelle in den Raum "gestellt" werden. Da die Kamera stationär im Zentrum des QTVR-Zylinders positioniert bleibt, stimmt die Perspektive zwischen Aussenraum (gerenderte/fotografierte Raumansicht) und dem zusätzlichen 3D-Model automatich – das Model muss nur noch so platziert werden, dass es im Verhältnis zum Raum-Abbild "natürlich" aussieht (Größe, Position, Ausleuchtung).
Hier wurde eine einfache #box dem Raum hinzugefügt und deren Zentrum so verschoben (paketModel.translate(68,-88,-168)), dass das Paket scheinbar "richtig" auf dem Tisch liegt:

Raum mit Paket-Model

Es fehlt (rein optisch) natürlich der Schatten, den das Paket auf die Tischoberfläche werfen müsste. Auch muss eine eigene Lichtquelle für das Paket definiert werden, die die Beleuchtung des Paket-Models der Lichtsituation im gerenderten/fotografierten Raum anpasst.

Ein anderes Beispiel für eine Animation mit einem zusätzlichen 3D-Model ist der Himmel mit den sich bewegenden Wolken. Dazu habe ich die Fenster des gerenderten Raums mit einem Alpha-Kanal transparent gemacht und um den QTVR-Zylinder einen zweiten, größeren Zylinder gebaut, auf dessen Innenseite die Himmelstextur aufgebracht ist. Wird der äußere Zylinder gedreht, ziehen auch die Wolken vorbei (allerdings immer von rechts nach links, egal in welcher "Himmelsrichtung" man aus dem Fenster schaut ;-)

 

8. Anlegen von Model & Resource (kubischesVR)

Derselbe Aufbau wie für ein zylindrisches QTVR gilt auch für ein kubisches, deswegen werden hier nur noch einmal die Unterschiede im Vergleich zu den vorher schon erklärten Script-Bausteinen aufgeführt.

Zu Beginn wird wieder ein Model und eine Resource benötigt, diesmal vom Typ #box:

property pMyQTVRmodel, pMyQTVRresource

on beginSprite me
  ...
  pMyQTVRmodel = gMy3Dworld.newModel("QTVRkubisch")

  pMyQTVRresource = gMy3Dworld.newModelResource("QTVRcubeResource", #box, #back)

  pTextureSize = member("CubeSource1").width
  pCubeRadius = pTextureSize/2

  pMyQTVRresource.height = pTextureSize
  pMyQTVRresource.width = pTextureSize
  pMyQTVRresource.length = pTextureSize

  pMyQTVRmodel.resource = pMyQTVRresource
  ...
end

Anstelle von Zylinderradius und -höhe geben jetzt die drei Parameter height/length/width die Dimensionen des Würfels an.
Dieser wird wieder so groß wie die Ursprungstextur in Pixeln, damit sich's nachher leichter rechnen läßt. Aus diesem Grund habe ich auch einen "Würfelradius" (
pCubeRadius) angelegt, den es in der Geometrie zwar so nicht gibt, der aber das ewige pTextureSize/2 erspart.

 

9. Anlegen von Textur & Shader (kubischesVR)

property pMyQTVRtextureList, pMyQTVRshaderList

on beginSprite me
  ...
  pMyQTVRtextureList = []
  pMyQTVRshaderList = []

  repeat with img = 1 to 6

    pMyQTVRtextureList[img] = gMy3Dworld.newTexture("QTVRcubeTex"&img, #fromCastMember, member("CubeSource"&img))
    pMyQTVRshaderList[img] = gMy3Dworld.newShader("QTVRcubeShader"&img, #standard)
    pMyQTVRshaderList[img].texture = pMyQTVRtextureList[img]

    -- pMyQTVRshaderList[img].textureTransform.scale(1.001,1.001,1.001)
    -- pMyQTVRshaderList[img].textureRepeat = False

    pMyQTVRshaderList[img].emissive = rgb(255, 255, 255)
    pMyQTVRshaderList[img].ambient = rgb(0,0,0)
    pMyQTVRshaderList[img].flat = true

  end repeat

  pMyQTVRmodel.shaderlist = pMyQTVRshaderList

  ...
end

Für die 6 Seiten des Würfels werden 6 Shader mit 6 verschiedenen Texturen benötigt. Deswegen geschieht die Zuweisung des fertigen Shaders zum Model nicht mehr mit
pMyQTVRmodel.shader = pMyQTVRshader
sondern mit
pMyQTVRmodel.shaderlist = pMyQTVRshaderList

Die Reihenfolge der Shader (und damit auch der Würfeltexturen) ist wie folgt:

Reihenfolge der Würfeltexturen

Zuerst kommen die 4 Seitenwände, dann der Deckel und schließlich der Boden. Die Ausrichtung von Boden und Deckel muss dabei so sein, dass das Texturmotiv wie oben mit den Zahlen 1-4 eingezeichnet an die Seitentexturen anschließt.

Die beiden auskommentierten Zeilen im obigen Script
-- pMyQTVRshaderList[img].textureTransform.scale(1.001,1.001,1.001)
-- pMyQTVRshaderList[img].textureRepeat = False
sind nur dann notwendig, wenn ohne sie eine sichtbare dünne schwarze Linie an den Würfelkanten entlangläuft:
Sichtbare Texturkanten

Diese Linie entsteht (oft) dadurch, dass die Texturen nicht die idealen Dimensionen (2er-Potenzen) haben und dadurch das "Nearfiltering" (die Rechenoperation, die die Textur auf die Fläche aufbringt) am Rand das "schwarze Universum" mit reinrechnet (sobald nearfiltering = false gesetzt wird, verschwindet normalerweise auch die Linie, dafür wird die Darstellung grobpixeliger):

Durch textureTransform.scale(1.001,1.001,1.001)wird die Textur minimal größer als die Fläche (der Shader) angelegt, dann sind die Texturkanten nicht mehr als dünne Linien sichtbar.

 

10. Navigation - Panning/Tilting/Zooming (kubischesVR)

Da der Würfel keine obere und untere Begrenzung mehr beim Neigen der Kamera hat, fällt alles, was mit pRemainingTilt zu tun hatte, ersatzlos aus den Scripten raus. Stattdessen ist ein pMaxTilt sinnvoll, der angibt, wieweit man den Blick nach oben/unten richten kann - ein Winkel so um die 90 Grad erlaubt die Sicht bis senkrecht nach oben/unten, wird weiter gekippt, läßt das den Raum kopfstehen...
Was auch noch bleibt, sind der pMinFov und der pMaxFov, beide können wir aber willkürlich festlegen (ist der pMinFov zu klein gewählt, kann man bis auf einzelne Pixel reinzoomen, ist der pMaxFov zu groß gewählt, wird der Raum zum Drogenrausch-Panorama ;-)

Die vereinfachte Navigation sieht dann so aus:

on beginSprite me
  ...
  pMinFov = 15.0
  pMaxFov = 72.0
  pMaxTilt = 91.0
  ...
end

on exitFrame me
  ...
  if gPanFlag then
    pMyCamera.rotate(0, gPanFlag * pPanAmount, 0, #world)
  end if

  if gZoomFlag then
    newFov = pMyCamera.fieldOfView + gZoomFlag*pZoomAmount
    if newFov > pMinFoV and newFov < pMaxFoV then
      pMyCamera.fieldOfView = newFov
    end if
  end if

  if gTiltFlag then
    newTilt = pTotalTilt + gTiltFlag*pTiltAmount
    if abs(newTilt) < pMaxTilt then
      pMyCamera.rotate(gTiltFlag*pTiltAmount, 0, 0)
      pTotalTilt = newTilt
    end if
  end if
  ...
end

 

11. Hotspot-Erkennung und -Interaktion (kubischesVR)

Auch bei kubischen VRs hat jedes SourcePict (jede Würfelseite) ein gleich großes Hotspot-Bitmap:

SourcePicts & Hotspot-Bitmaps

Die Hotspot-Erkennung über die isectPosition funktioniert im Prinzip gleich wie beim Zylinder – da die Flächen des Würfels im Raum immer senkrecht zu den Achsen stehen, ist eine der drei isectPosition-Koordinaten dx, dy und dz immer gleich dem pCubeRadius. Die anderen beiden müssen auf die Texturpixel-Koordinaten umgemappt werden, hier am Beispiel der Würfelseite 2:

Hotspots auf Fläche 2

Der dx-Wert ist hier gleich dem pCubeRadius, der dz-Wert entspricht demTextur-X-Wert und der dy-Wert dem Textur-Y-Wert.
Für alle 6 Würfelseiten sieht das Script dann so aus:

on exitFrame me
  ...
  iSectData = pMyCamera.modelsUnderLoc(the mouseLoc - pMyOrigin, 1, #detailed)

  iSectWorldCoord = iSectData[1].isectPosition
  dx = iSectWorldCoord[1]
  dy = iSectWorldCoord[2]
  dz = iSectWorldCoord[3]

  meshNumber = iSectData[1].meshID

  case meshNumber of
    1: x = pCubeRadius + dx
     y = pCubeRadius - dy
    2: x = pCubeRadius + dz
     y = pCubeRadius - dy
    3: x = pCubeRadius - dx
     y = pCubeRadius - dy
    4: x = pCubeRadius - dz
     y = pCubeRadius - dy
    5: x = pCubeRadius + dx
     y = pCubeRadius - dz
    6: x = pCubeRadius - dx
     y = pCubeRadius - dz
  end case

  newHotSpotNum = gHotSpotImageList[meshNumber].getPixel(x, y, #integer)
  ...
end

 

 

12. Animationen (kubischesVR)

Siehe Animationen (zylindrisch) ;-)

Kritik, Anregungen und Kommentare zu diesem Vortrag gerne an:

Joachim Baur | medienwerkstatt | grafik-design