标签 Cpp 下的文章

理解Unikernels

Docker, Inc在今年年初宣布收购Unikernel Systems公司时,Unikernel对大多数技术人员来说还是很陌生的。直到今天,知名问答类网站知乎上也没有以Unikernel为名字的子话题。国内搜索引擎中关于Unikernel的内容很少,实践相关的内容就更少了。Docker收购Unikernel Systems,显然不是为了将这个其未来潜在的竞争对手干掉,而是嗅到了Unikernel身上的某些技术潜质。和关注Docker一样,本博客后续将持续关注Unikernel的最新发展和优秀实践,并将一些国外的优秀资料搬(翻)移(译)过来供国内Unikernel爱好者和研究人员参考。

本文翻译自BSD Magazine2016年第3期中Russell Pavlicek的文章《Understanding Unikernels》,译文全文如下。

当我们描述一台机器(物理的或虚拟的)上的操作系统内核时,我们通常所指的是运行在特定处理器模式(内核模式)下且所使用的地址空间有别于机器上其他软件运行地址空间的一段特定的软件代码。操作系统内核通常用于提供一些关键的底层函数,这些函数被操作系统中其他软件所使用。内核通常是一段通用的代码,(有需要时)一般会被做适当裁剪以适配支持机器上的应用软件栈。这个通用的内核通常会提供各种功能丰富的函数,但很多功能和函数并不是内核支持的特定应用程序所需要的。

事实上,如果看看今天大多数机器上运行的整体软件栈,我们会发现很难弄清楚到底哪些应用程序运行在那台机器上了。你可能会发现即便没有上千,也会有成百计的低级别实用程序(译注:主要是指系统引导起来后,常驻后台的一些系统服务程序),外加许多数据库程序,一两个Web服务程序,以及一些指定的应用程序。这台机器可能实际上只承担运行一个单独的应用程序,或者它也可能被用于同时运行许多应用。通过对系统启动脚本的细致分析来确定最终运行程序的集合是一个思路,但还远非精准。因为任何一个具有适当特权的用户都可以去启动系统中已有应用程序中的任何一个。

Unikernel的不同之处

基于Unikernel的机器的覆盖面(footprint)是完全不同的。在物理机器(或虚拟机映像)中,Unikernel扮演的角色与其他内核是相似的,但实现特征显著不同。

例如,对一个基于Unikernel的机器的代码进行分析就不会受到大多数其他软件栈的模糊性的影响。当你考虑分析一个Unikernel系统时,你会发现系统中只存在一个且只有一个应用程序。那种标准的多应用程序软件栈不见了,前面提到的过多的通用实用程序和支持函数也不见了。不过裁剪并未到此打住。不仅应用软件栈被裁剪到了最低限度,操作系统功能也同样被剪裁了。例如,多用户支持、多进程支持以及高级内存管理也都不见了。

认为这很激进?想想看:如果整个独立的操作系统层也不见了呢!内核不再有独立的地址空间,应用程序也不再有独立的地址空间了。为什么?因为内核的功能函数和应用程序现在都成为了同一个程序的一部分。事实上,整个软件栈是由一个单独的软件程序构成的,这个程序负责提供应用程序所需的所有代码以及操作系统的功能函数。如果这还不够的话,只需在Unikernel中提供应用所需的那些功能函数即可,所有其他应用程序所不需要的操作系统功能函数都会被整体移除掉。

一个反映新世纪现实的软件栈

Unikernel的出现,其背后的目的在于对这个行业的彻底的反思。几十年来,在这个行业里我们的工作一直伴随着这样一个理念:机器的最好架构是基于一个通用多用户操作系统启动,加载一系列有用的实用工具程序,添加我们可能需要使用的应用程序。最后,再使用一些包管理软件来管理这种混乱的情况。

35年前,这种做法是合乎情理的。那个时候,硬件很昂贵,虚拟化的选择非常有限甚至是不可用。安全仅局限于保证计算中心坐在你身旁的人没有在偷看你输密码。一台机器需要同时处理许多用户运行的许多应用程序以保证较高的成本效益。当我还在大学(1、2千年前。 译注:作者开玩笑,强调那时的古老^_^)时,在个人计算机出现之前,学校计算机中心有一个超级昂贵的机器(以今天的标准来看) – 一台DEC PDP-11/34a,配置了248K字节的内存和25M磁盘,为全校的计算机科学、工程以及数学专业的学生使用。这台机器必须服务于几百名学生每个学期想出的每个功能。

对比计算机历史上那个远古时代的恐龙和现代的智能手机,你会发现手机拥有的计算能力高出那台机器几个数量级。这样一来,我们为什么还要用在计算机石器时代所使用的那些原则去创建机器内核映像呢?重新思考与新的计算现实相匹配的软件栈难道不是很有意义吗?

在现代世界,硬件十分便宜。虚拟化无处不在且运行效率很高。几乎所有计算设备都连接在一个巨大的、世界范围的且存在潜在恶意黑客的网络中。想想看:一台DNS服务器真的不需要上千兆的字节去完成它的工作;一台应用服务器也真的不需要为刚刚利用一个漏洞获得虚拟命令行访问权的黑客准备数千实用工具程序。 一个Web服务器并不需要验证500个不同的分时用户的命令行登录。那么为什么我们现在仍然在使用支持这些不需要的场景的过时的软件栈概念呢?

Unikernel的美丽新世界

那么一个现代软件栈应该是什么样子的呢?下面这个怎么样:单一应用映像,虚拟化的,高度安全的,超轻量的,具有超快启动速度。这些正是Unikernel所能提供的。我们逐一来说:

单一映像

叠加在一个通用内核上的数以百计的实用工具程序和大量应用程序被一个可执行体所替代。这个可执行体将所有需要的应用程序和操作系统代码放置在一个单一的映像中。它只包含它所需要的。

虚拟化的

就在几年前,你可以很幸运地在一台服务器上启动少量虚拟机。硬件的内存限制以及守旧的、吃内存的软件栈不允许你在一台服务器上同时启动太多虚机。今天我们有了配置了数千兆内存的高性能服务器,我们不再满足于每台机器仅能启动少量虚机了。如果每个虚机映像足够小,我们可以在一个服务器上同事运行数百个,甚至上千个虚机应用。

安全

在云计算时代,我们发现恶意黑客可以例行公事般入侵各地的服务器,即便是那些知名大公司和政府机构的服务器也不例外。这些违规行为常常是利用了某个网络服务的缺陷并进入了软件栈的更低层。从那开始,恶意入侵者可以利用系统中已有的实用程序或其他应用程序来实施他们的邪恶行为。在Unikernel栈中,没有其他软件可以协助这些恶意的黑客。黑客必须足够聪明才能入侵其中的应用程序,但接下来还是没有驻留的工具可以用来协助做坏事。虽然Unikernel栈不会使得软件彻底完全的变安全,但是它确能显著提升软件的安全级别。并且这是云计算时代长期未兑现的一种进步。

超轻量

一个正常的VM仅仅是为了能在网络中提供少量的服务就要占用千兆的磁盘和内存空间。若使用Unikernel,我们可以不再纠结于这些资源需求。例如,使用MirageOS(一个非常流行的Unikernel系统),我们可以构建出一个具备DNS服务功能的VM映像,其占用的磁盘空间仅仅为449K – 是的,还不到半兆。使用ClickOS,一个来自NEC实验室的网络应用Unikernel系统制作的网络设备仅仅使用6兆内存却可以成功达到每秒5百万包的处理能力。这些绝不是基于Unikernel的设备的非典型例子。鉴于Unikernels的小巧精简,在单主机服务器上启动数百或数千这类微小虚拟机的想法似乎不再遥不可及。

快速启动

普通VM的引导启动消耗较长时间。在现代硬件上启动一个完整操作系统以及软件栈直到服务上线需要花费一分钟甚至更多的时间。但是对于基于Unikernel的VM来说,这种情况却不适用。绝大多数的Unikernel VM引导启动时间少于十分之一秒。例如,ClickOS网络VM文档中记录的引导启动时间在30毫秒以下。这个速度快到足以在服务请求到达网络时再启动一个用于处理该请求的VM了(这正是Jitsu项目所要做的事情,参见http://unikernel.org/files/2015-nsdi-jitsu.pdf)。

但是,容器不已经做到这一点了吗?

在创建轻量级,快速启动的VM方面,容器已经走出了很远。但在幕后容器依然依赖着一个共享的、健壮的操作系统。从安全的角度来看,容器还有很多要锁定的地方。很明显我们需要加强我们在云中的安全,但不是去追求这些相同的、陈旧的、在云中就会快速变得漏洞百出的安全方法。除此之外,Unikernel的最终覆盖面仍然要比容器能提供的小得很多。因此容器走在了正确的方向上,而Unikernel则设法在这个未来云所需要的方向上走的更远。

Unikernels是如何工作的?

正如之前提到的,传统机器自底向上构建:你选择一个通用的操作系统内核,添加大量实用工具程序,最后添加应用程序。Unikernel正好相反:它们是自底向上构建的。聚焦在你要运行的应用程序上,恰到好处地添加使其刚好能运行的操作系统函数。大多数Unikernel系统依靠一个编译链接系统,这个系统编译应用程序源码并将应用程序所需的操作系统函数库链接进来,形成一个单独的编译映像。无需其他软件,这个映像就可以运行在VM中。

如何对结果进行调试?

由于在最终的成品中没有操作系统或实用工具程序,绝大多数Unikernel系统使用了一种分阶段的方法来开发。通常,在开发阶段一次编译会生成一个适合在Linux或类Unix操作系统上进行测试的可执行程序。这个可执行程序可以运行和被调试,就像任何一个标准程序那样。一旦你对测试结果感到满意,你可以重新编译,打开开关,创建独立运行在VM中的最终映像。

在生产环境机器上缺少调试工具并没有最初想象的那样糟糕。绝大多数组织不允许开发人员在生产机器上调试,相反,他们收集日志和其他信息,在开发平台重现失败场景,修正问题并重新部署。这个事实让调试生产映像的限制也有所缓和。在Unikernel世界中,这个操作顺序也已具备。你只需要保证你的生产环境映像可以输出足够多的日志以方便重构失败场景。你的标准应用程序可能正在做这些事情了。

有哪些可用的Unikernel系统?

现在有很多Unikernel可供选择,它们支持多种编程语言,并且Unikernel项目还在持续增加中。一些较受欢迎的Unikernel系统包括:

  • MirageOS:最早的Unikernels系统之一,它使用Ocaml语言;
  • HaLVM:另外一个早期Unikernels系统,由Haskell语言实现;
  • LING:历史悠久的项目,使用Erlang实现;
  • ClickOS:为网络应用优化的系统,支持C、C++和Python;
  • OSv:稍有不同的Unikernel系统,它基于Java,并支持其他一些编程语言。支持绝大多数JAR文件部署和运行。
  • Rumprun:使用了来自NetBSD项目的模块代码,目标定位于任何符合POSIX标准的、不需要Fork的应用程序,特别适合将现有程序移植到Unikernel世界。

Unikernel是灵丹妙药吗?

Unikernel远非万能的。由于他们是单一进程实体,运行在单一地址空间,没有高级内存管理,很多程序无法很容易地迁移到Unikernel世界。不过,运行于世界各地数据中心中的大量服务很适合该方案。将这些服务转换为轻量级Unikernel,我们可以重新分配服务器能力,任务较重的服务可以从额外的资源中受益。

转换成Unikernel的任务数量比你想象的要多。在2015年,Martin Lucina宣布成功创建了一个”RAMP”栈 – LAMP栈(Linux、Apache、MySQL和PHP/Python)的变种。RAMP栈使用了NGINX,MySQL和PHP,它们都构建在Rumprun之上。Rumprun是Rump内核的一个实例,而Rump内核则是基于NetBSD工程模块化操作系统功能函数集合的一个Unikernel系统。所以这种常见的解决方案堆栈可以成功地转化迁移到Unikernels世界中。

更多信息

要想学习更多有关Unikernels方面的内容,可以访问http://www.unikernel.org或观看2015年我在Southeast Linuxfest的演讲视频

Golang程序配置方案小结

在Twitter上看到一篇关于Golang程序配置方案总结的系列文章(一个mini series,共6篇),原文链接:在这里。我觉得不错,这里粗略整理(非全文翻译)一下,供大家参考。

一、背景

无论使用任何编程语言开发应用,都离不开配置数据。配置数据提供的形式有多样,不外乎命令行选项(options)、参数(parameters),环境 变量(env vars)以及配置文件等。Golang也不例外。Golang内置flag标准库,可以用来支持部分命令行选项和参数的解析;Golang通过os包提 供的方法可以获取当前环境变量;但Golang没有规定标准配置文件格式(虽说内置支持xml、json),多通过第三方 包来解决配置文件读取的问题。Golang配置相关的第三方包邮很多,作者在本文中给出的配置方案中就包含了主流的第三方配置数据操作包。

文章作者认为一个良好的应用配置层次应该是这样的:
1、程序内内置配置项的初始默认值
2、配置文件中的配置项值可以覆盖(override)程序内配置项的默认值。
3、命令行选项和参数值具有最高优先级,可以override前两层的配置项值。

下面就按作者的思路循序渐进探讨golang程序配置方案。

二、解析命令行选项和参数

这一节关注golang程序如何访问命令行选项和参数。

golang对访问到命令行参数提供了内建的支持:

//cmdlineargs.go
package main

import (
    //      "fmt"
    "os"
    "path/filepath"
)

func main() {
    println("I am ", os.Args[0])

    baseName := filepath.Base(os.Args[0])
    println("The base name is ", baseName)

    // The length of array a can be discovered using the built-in function len
    println("Argument # is ", len(os.Args))

    // the first command line arguments
    if len(os.Args) > 1 {
        println("The first command line argument: ", os.Args[1])
    }
}

执行结果如下:
$go build cmdlineargs.go
$cmdlineargs test one
I am  cmdlineargs
The base name is  cmdlineargs
Argument # is  3
The first command line argument:  test

对于命令行结构复杂一些的程序,我们最起码要用到golang标准库内置的flag包:

//cmdlineflag.go
package main

import (
    "flag"
    "fmt"
    "os"
    "strconv"
)

var (
    // main operation modes
    write = flag.Bool("w", false, "write result back instead of stdout\n\t\tDefault: No write back")

    // layout control
    tabWidth = flag.Int("tabwidth", 8, "tab width\n\t\tDefault: Standard")

    // debugging
    cpuprofile = flag.String("cpuprofile", "", "write cpu profile to this file\n\t\tDefault: no default")
)

func usage() {
    // Fprintf allows us to print to a specifed file handle or stream
    fmt.Fprintf(os.Stderr, "\nUsage: %s [flags] file [path ...]\n\n",
        "CommandLineFlag") // os.Args[0]
    flag.PrintDefaults()
    os.Exit(0)
}

func main() {
    fmt.Printf("Before parsing the flags\n")
    fmt.Printf("T: %d\nW: %s\nC: '%s'\n",
        *tabWidth, strconv.FormatBool(*write), *cpuprofile)

    flag.Usage = usage
    flag.Parse()

    // There is also a mandatory non-flag arguments
    if len(flag.Args()) < 1 {
        usage()
    }
   
    fmt.Printf("Testing the flag package\n")
    fmt.Printf("T: %d\nW: %s\nC: '%s'\n",
        *tabWidth, strconv.FormatBool(*write), *cpuprofile)

    for index, element := range flag.Args() {
        fmt.Printf("I: %d C: '%s'\n", index, element)
    }
}

这个例子中:
- 说明了三种类型标志的用法:Int、String和Bool。
- 说明了每个标志的定义都由类型、命令行选项文本、默认值以及含义解释组成。
- 最后说明了如何处理标志选项(flag option)以及非option参数。

不带参数运行:

$cmdlineflag
Before parsing the flags
T: 8
W: false
C: ''

Usage: CommandLineFlag [flags] file [path ...]

  -cpuprofile="": write cpu profile to this file
        Default: no default
  -tabwidth=8: tab width
        Default: Standard
  -w=false: write result back instead of stdout
        Default: No write back

带命令行标志以及参数运行(一个没有flag,一个有两个flag):

$cmdlineflag aa bb
Before parsing the flags
T: 8
W: false
C: ''
Testing the flag package
T: 8
W: false
C: ''
I: 0 C: 'aa'
I: 1 C: 'bb'

$cmdlineflag -tabwidth=2 -w aa
Before parsing the flags
T: 8
W: false
C: ''
Testing the flag package
T: 2
W: true
C: ''
I: 0 C: 'aa'

从例子可以看出,简单情形下,你无需编写自己的命令行parser或使用第三方包,使用go内建的flag包即可以很好的完成工作。但是golang的 flag包与命令行Parser的事实标准:Posix getopt(C/C++/Perl/Shell脚本都可用)相比,还有较大差距,主要体现在:

1、无法支持区分long option和short option,比如:-h和–help。
2、不支持short options合并,比如:ls -l -h <=> ls -hl
3、命令行标志的位置不能任意放置,比如无法放在non-flag parameter的后面。

不过毕竟flag是golang内置标准库包,你无须付出任何cost,就能使用它的功能。另外支持bool型的flag也是其一大亮点。

三、TOML,Go配置文件的事实标准(这个可能不能得到认同)

命令行虽然是一种可选的配置方案,但更多的时候,我们使用配置文件来存储静态的配置数据。就像Java配xml,ruby配yaml,windows配 ini,Go也有自己的搭配组合,那就是TOML(Tom's Obvious, Minimal Language)。

初看toml语法有些类似windows ini,但细致研究你会发现它远比ini强大的多,下面是一个toml配置文件例子:

# This is a TOML document. Boom.

title = "TOML Example"

[owner]
name = "Lance Uppercut"
dob = 1979-05-27T07:32:00-08:00 # First class dates? Why not?

[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true

[servers]

  # You can indent as you please. Tabs or spaces. TOML don't care.
  [servers.alpha]
  ip = "10.0.0.1"
  dc = "eqdc10"

  [servers.beta]
  ip = "10.0.0.2"
  dc = "eqdc10"

[clients]
data = [ ["gamma", "delta"], [1, 2] ]

# Line breaks are OK when inside arrays
hosts = [
  "alpha",
  "omega"
]

看起来很强大,也很复杂,但解析起来却很简单。以下面这个toml 文件为例:

Age = 25
Cats = [ "Cauchy", "Plato" ]
Pi = 3.14
Perfection = [ 6, 28, 496, 8128 ]
DOB = 1987-07-05T05:45:00Z

和所有其他配置文件parser类似,这个配置文件中的数据可以被直接解析成一个golang struct:

type Config struct {
  Age int
  Cats []string
  Pi float64
  Perfection []int
  DOB time.Time // requires `import time`
}

其解析的步骤也很简单:

var conf Config
if _, err := toml.Decode(tomlData, &conf); err != nil {
  // handle error
}

是不是简单的不能简单了!

不过toml也有其不足之处。想想如果你需要使用命令行选项的参数值来覆盖这些配置文件中的选项,你应该怎么做?事实上,我们常常会碰到类似下面这种三层配置结构的情况:

1、程序内内置配置项的初始默认值
2、配置文件中的配置项值可以覆盖(override)程序内配置项的默认值。
3、命令行选项和参数值具有最高优先级,可以override前两层的配置项值。

在go中,toml映射的结果体字段没有初始值。而且go内建flag包也没有将命令行参数值解析为一个go结构体,而是零散的变量。这些可以通过第三方工具来解决,但如果你不想用第三方工具,你也可以像下面这样自己解决,虽然难看一些。

func ConfigGet() *Config {
    var err error
    var cf *Config = NewConfig()

    // set default values defined in the program
    cf.ConfigFromFlag()
    //log.Printf("P: %d, B: '%s', F: '%s'\n", cf.MaxProcs, cf.Webapp.Path)

    // Load config file, from flag or env (if specified)
    _, err = cf.ConfigFromFile(*configFile, os.Getenv("APPCONFIG"))
    if err != nil {
        log.Fatal(err)
    }
    //log.Printf("P: %d, B: '%s', F: '%s'\n", cf.MaxProcs, cf.Webapp.Path)

    // Override values from command line flags
    cf.ConfigToFlag()
    flag.Usage = usage
    flag.Parse()
    cf.ConfigFromFlag()
    //log.Printf("P: %d, B: '%s', F: '%s'\n", cf.MaxProcs, cf.Webapp.Path)

    cf.ConfigApply()

    return cf
}

就像上面代码中那样,你需要:
1、用命令行标志默认值设置配置(cf)默认值。
2、接下来加载配置文件
3、用配置值(cf)覆盖命令行标志变量值
4、解析命令行参数
5、用命令行标志变量值覆盖配置(cf)值。

少一步你都无法实现三层配置能力。

四、超越TOML

本节将关注如何克服TOML的各种局限。

为了达成这个目标,很多人会说:使用viper,不过在介绍viper这一重量级选手 之前,我要为大家介绍另外一位不那么知名的选手:multiconfig

有些人总是认为大的就是好的,但我相信适合的还是更好的。因为:

1、viper太重量级,使用viper时你需要pull另外20个viper依赖的第三方包
2、事实上,viper单独使用还不足以满足需求,要想得到viper全部功能,你还需要另外一个包配合,而后者又依赖13个外部包
3、与viper相比,multiconfig使用起来更简单。

好了,我们再来回顾一下我们现在面临的问题:

1、在程序里定义默认配置,这样我们就无需再在toml中定义它们了。
2、用toml配置文件中的数据override默认配置
3、用命令行或环境变量的值override从toml中读取的配置。

下面是一个说明如何使用multiconfig的例子:

func main() {
    m := multiconfig.NewWithPath("config.toml") // supports TOML and JSON

    // Get an empty struct for your configuration
    serverConf := new(Server)

    // Populated the serverConf struct
    m.MustLoad(serverConf) // Check for error

    fmt.Println("After Loading: ")
    fmt.Printf("%+v\n", serverConf)

    if serverConf.Enabled {
        fmt.Println("Enabled field is set to true")
    } else {
        fmt.Println("Enabled field is set to false")
    }
}

这个例子中的toml文件如下:

Name              = "koding"
Enabled           = false
Port              = 6066
Users             = ["ankara", "istanbul"]

[Postgres]
Enabled           = true
Port              = 5432
Hosts             = ["192.168.2.1", "192.168.2.2", "192.168.2.3"]
AvailabilityRatio = 8.23

toml映射后的go结构如下:

type (
    // Server holds supported types by the multiconfig package
    Server struct {
        Name     string
        Port     int `default:"6060"`
        Enabled  bool
        Users    []string
        Postgres Postgres
    }

    // Postgres is here for embedded struct feature
    Postgres struct {
        Enabled           bool
        Port              int
        Hosts             []string
        DBName            string
        AvailabilityRatio float64
    }
)

multiconfig的使用是不是很简单,后续与viper对比后,你会同意我的观点的。

multiconfig支持默认值,也支持显式的字段赋值需求。
支持toml、json、结构体标签(struct tags)以及环境变量。
你可以自定义配置源(例如一个远程服务器),如果你想这么做的话。
可高度扩展(通过loader接口),你可以创建你自己的loader。

下面是例子的运行结果,首先是usage help:

$cmdlinemulticonfig -help
Usage of cmdlinemulticonfig:
  -enabled=false: Change value of Enabled.
  -name=koding: Change value of Name.
  -port=6066: Change value of Port.
  -postgres-availabilityratio=8.23: Change value of Postgres-AvailabilityRatio.
  -postgres-dbname=: Change value of Postgres-DBName.
  -postgres-enabled=true: Change value of Postgres-Enabled.
  -postgres-hosts=[192.168.2.1 192.168.2.2 192.168.2.3]: Change value of Postgres-Hosts.
  -postgres-port=5432: Change value of Postgres-Port.
  -users=[ankara istanbul]: Change value of Users.

Generated environment variables:
   SERVER_NAME
   SERVER_PORT
   SERVER_ENABLED
   SERVER_USERS
   SERVER_POSTGRES_ENABLED
   SERVER_POSTGRES_PORT
   SERVER_POSTGRES_HOSTS
   SERVER_POSTGRES_DBNAME
   SERVER_POSTGRES_AVAILABILITYRATIO

$cmdlinemulticonfig
After Loading:
&{Name:koding Port:6066 Enabled:false Users:[ankara istanbul] Postgres:{Enabled:true Port:5432 Hosts:[192.168.2.1 192.168.2.2 192.168.2.3] DBName: AvailabilityRatio:8.23}}
Enabled field is set to false

检查一下输出结果吧,是不是每项都符合我们之前的预期呢!

五、Viper

我们的重量级选手viper(https://github.com/spf13/viper)该出场了!

毫无疑问,viper非常强大。但如果你想用命令行参数覆盖预定义的配置项值,viper自己还不足以。要想让viper爆发,你需要另外一个包配合,它就是cobra(https://github.com/spf13/cobra)。

不同于注重简化配置处理的multiconfig,viper让你拥有全面控制力。不幸的是,在得到这种控制力之前,你需要做一些体力活。

我们再来回顾一下使用multiconfig处理配置的代码:

func main() {
    m := multiconfig.NewWithPath("config.toml") // supports TOML and JSON

    // Get an empty struct for your configuration
    serverConf := new(Server)

    // Populated the serverConf struct
    m.MustLoad(serverConf) // Check for error

    fmt.Println("After Loading: ")
    fmt.Printf("%+v\n", serverConf)

    if serverConf.Enabled {
        fmt.Println("Enabled field is set to true")
    } else {
        fmt.Println("Enabled field is set to false")
    }
}

这就是使用multiconfig时你要做的所有事情。现在我们来看看使用viper和cobra如何来完成同样的事情:

func init() {
    mainCmd.AddCommand(versionCmd)

    viper.SetEnvPrefix("DISPATCH")
    viper.AutomaticEnv()

    /*
      When AutomaticEnv called, Viper will check for an environment variable any
      time a viper.Get request is made. It will apply the following rules. It
      will check for a environment variable with a name matching the key
      uppercased and prefixed with the EnvPrefix if set.
    */

    flags := mainCmd.Flags()

    flags.Bool("debug", false, "Turn on debugging.")
    flags.String("addr", "localhost:5002", "Address of the service")
    flags.String("smtp-addr", "localhost:25", "Address of the SMTP server")
    flags.String("smtp-user", "", "User to authenticate with the SMTP server")
    flags.String("smtp-password", "", "Password to authenticate with the SMTP server")
    flags.String("email-from", "noreply@example.com", "The from email address.")

    viper.BindPFlag("debug", flags.Lookup("debug"))
    viper.BindPFlag("addr", flags.Lookup("addr"))
    viper.BindPFlag("smtp_addr", flags.Lookup("smtp-addr"))
    viper.BindPFlag("smtp_user", flags.Lookup("smtp-user"))
    viper.BindPFlag("smtp_password", flags.Lookup("smtp-password"))
    viper.BindPFlag("email_from", flags.Lookup("email-from"))

  // Viper supports reading from yaml, toml and/or json files. Viper can
  // search multiple paths. Paths will be searched in the order they are
  // provided. Searches stopped once Config File found.

    viper.SetConfigName("CommandLineCV") // name of config file (without extension)
    viper.AddConfigPath("/tmp")          // path to look for the config file in
    viper.AddConfigPath(".")             // more path to look for the config files

    err := viper.ReadInConfig()
    if err != nil {
        println("No config file found. Using built-in defaults.")
    }
}

可以看出,你需要使用BindPFlag来让viper和cobra结合一起工作。但这还不算太糟。

cobra的真正威力在于提供了subcommand能力。同时cobra还提供了与posix 全面兼容的命令行标志解析能力,包括长短标志、内嵌命令、为command定义你自己的help或usage等。

下面是定义子命令的例子代码:

// The main command describes the service and defaults to printing the
// help message.
var mainCmd = &cobra.Command{
    Use:   "dispatch",
    Short: "Event dispatch service.",
    Long:  `HTTP service that consumes events and dispatches them to subscribers.`,
    Run: func(cmd *cobra.Command, args []string) {
        serve()
    },
}

// The version command prints this service.
var versionCmd = &cobra.Command{
    Use:   "version",
    Short: "Print the version.",
    Long:  "The version of the dispatch service.",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println(version)
    },
}

有了上面subcommand的定义,我们就可以得到如下的help信息了:

Usage:
  dispatch [flags]
  dispatch [command]

Available Commands:
  version     Print the version.
  help        Help about any command

Flags:
      –addr="localhost:5002": Address of the service
      –debug=false: Turn on debugging.
      –email-from="noreply@example.com": The from email address.
  -h, –help=false: help for dispatch
      –smtp-addr="localhost:25": Address of the SMTP server
      –smtp-password="": Password to authenticate with the SMTP server
      –smtp-user="": User to authenticate with the SMTP server

Use "dispatch help [command]" for more information about a command.

六、小结

以上例子的完整源码在作者的github repository里可以找到。

关于golang配置文件,我个人用到了toml这一层次,因为不需要太复杂的配置,不需要环境变量或命令行override默认值或配置文件数据。不过 从作者的例子中可以看到multiconfig、viper的确强大,后续在实现复杂的golang应用时会考虑真正应用。

Cocos2d-x屏幕适配之Sprite绘制原理

手机(智能终端)游戏绝大多数为全屏(Full Screen)显示,这样开发人员在制作游戏时势必要考虑不同手机(智能终端)屏幕大小、宽高比的不同给游戏画面带来的影响,并且要将这种影响降低到最 小,努力使用不同终端的游戏玩家拥有几乎相同的游戏画面体验。为此各种游戏引擎在屏幕适配方面都给出了自己的方案,Cocos2d-x也不例外。 在Cocos2d-x官网Wiki上特地撰写了一篇讲解Cocos2d-x多屏幕适配原理的文章“Detailed explanation of Cocos2d-x Multi-resolution adaptation”。

这里我们以Cocos2d-x引擎(基于2.2.2版本)自带的Sample项目HelloCpp(cocos2d-x-2.2.2/samples/Cpp/HelloCpp)为例,直观的看看这个方案带来的好 处。首先,我们对HelloCpp项目做些许改造:
    – 注释掉AppDelegate.cpp中applicationDidFinishLaunching下的pEGLView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, kResolutionNoBorder);
    – 仅使用Resource/iphone下的资源,即仅searchPath.push_back(smallResource.directory); 这里我们有一张480×320分辨率大小PNG文件。
    – 通过改变proj.linux/main.cpp中的eglView->setFrameSize(960, 640);来改变屏幕参数。(用linux工程模拟甚为方便,编译和运行占用资源小,极为迅捷,效果与Android平台是等 效的)

我们对比一下以下三种条件下的游戏Demo显示结果:
    1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320);

如我们所料,我们得到三个截然不同的结果。

第一种情况,我们所得到的游戏屏幕截图如下:

第二种情况,我们所得到的游戏屏幕截图如下:

第三种情况,我们所得到的游戏屏幕截图如下:

第一种情况是最理想的情况,屏幕大小与背景图片大小相同,如我们所愿,屏幕与背景图片吻合的天衣无缝。
第二种情况显然是模拟我们初次遇到问题的场景。屏幕Size扩大为原先的二倍,在资源没有变化的情况下,我们发现480×320大小的背景图片没 有铺满屏幕,仅仅是居中显示,并在四周露出较多”黑边“,这显然不是我们想要的。
第三种情况,也就是我们按照官方屏幕适配方案调整后得到的结果,在资源依旧不变的情况下,我们得到了相对令人满意的结果:背景图片恰如其分的铺满 整个屏幕,比例正确。这样我们用一套资源就可以同时适配两个屏幕了:480×320、960×640。这两种终端的玩家至少不会对我们的游戏心生 抱怨之情^_^。

当然在遇到第二种情况的时候,你也大可再准备一套新资源,比如一张960×640的背景图片。在480×320手机上,使用480×320的图 片;在960×640的手机上,使用960×640的背景图片。但这种方法的弊端至少有三:
    – 包大了:游戏的安装包Size急剧变大。
    – 活儿多了:因适配屏幕种类太多而制作大量的图片。
    – 新屏幕出来咋办:如果某个厂家突然于某天出品一款手机,其分辨率与以往市面上的所有手机均不同,那你的游戏因没有对应的资源,肯定无法很好适配该手机,导 致较差用户体验。

为此,适配屏幕唯一的出路似乎只有按照官方推荐的方案进行了,当然适当结合有限种类的资源也许可以更好的提升游戏体验。

如果仅仅从游戏制作角度来看,我们找到了可以适配屏幕的方法就可以了,没有必要刨根问底。甚至当有人问起来:为何 setDesignResolutionSize后,背景图片就可以充满屏幕了呢?我们可以回答:“引擎对精灵进行了缩放,就是这样”。但对于上 面的背景精灵来说,真的是我们理解的普通意义上的“精灵缩放(Scale)吗?本着“知其然,也要知其所以然”的精神,这里对引擎如何对 Sprite进行绘制进行了一番研究,我还真发现了一些与我之前理解差异较大的“深奥”原理,这里与大家一起分享一下。

一、绘制参数初始化

我们还是从代码开始,了解一下引擎绘制参数的初始化工作是如何做的、在哪里做的,为后续的分析做些铺垫。这里以Cocos2d-x 2.2.2 Android平台为例。关于Cocos2d-x 2.2.2 Android平台的引擎粗线条启动流程分析,可以参考《Hello,Cocos2d-x》这篇文章。看完这篇文章,你就会知道我们这次应该从Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit开 始。

// samples/Cpp/HelloCpp/proj.android/jni/hellocpp/main.cpp
void Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit(
               JNIEnv*  env, jobject thiz, jint w, jint h)
{
    if (!CCDirector::sharedDirector()->getOpenGLView())
    {
        CCEGLView *view = CCEGLView::sharedOpenGLView();
        view->setFrameSize(w, h);

        AppDelegate *pAppDelegate = new AppDelegate();
        CCApplication::sharedApplication()->run();
    }
    … …
}

这里是引擎部分初始化的起点:CCDirector和CCEGLView先后完成创建与初始化。接下来我们分别看一下这两个过程,我们主要关 注与绘制参数设置相关的内容:

bool CCDirector::init(void)
{
    setDefaultValues();

    … …
    m_obWinSizeInPoints = CCSizeZero;

    m_pobOpenGLView = NULL;

    m_fContentScaleFactor = 1.0f;
    … …
    return true;
}

void CCDirector::setDefaultValues(void)
{
    CCConfiguration *conf =
     CCConfiguration::sharedConfiguration();
    … …
    // GL projection
    const char *projection =
        conf->getCString("cocos2d.x.gl.projection",
                         "3d");
    if( strcmp(projection, "3d") == 0 )
        m_eProjection = kCCDirectorProjection3D;
    … …
}

由于conf中没有配置“cocos2d.x.gl.projection”,因此projection使用了 getCString传入的默认值:"3d",m_eProjection则被赋值为kCCDirectorProjection3D。

CCEGLView的创建更为简单:

CCEGLView::CCEGLView()
{
    initExtensions();
}

但背后真正发挥关键作用的是其父类CCEGLViewProtocol。

CCEGLViewProtocol::CCEGLViewProtocol()
: m_pDelegate(NULL)
, m_fScaleX(1.0f)
, m_fScaleY(1.0f)
, m_eResolutionPolicy(kResolutionUnKnown)
{
}

这里我们看到了三个重要的字段:m_fScaleX、m_fScaleY以及m_eResolutionPolicy,这三个字段对于后续屏 幕适配起到至关重要的作用。

nativeInit中的view->SetFrameSize(w, h)用于设置的屏幕物理分辨率,如果你的手机是960×640分辨率的,那FrameSize就是960×640。

void CCEGLViewProtocol::setFrameSize(float width,
                                     float height)
{
    m_obDesignResolutionSize
      = m_obScreenSize
      = CCSizeMake(width, height);
}
初始情况下,CCEGLViewProtocol将“设计分辨率”m_obDesignResolutionSize也设置为与 FrameSize or m_obScreenSize同等大小。

我们回到游戏逻辑层代码AppDelegate.cpp,我们知道游戏逻辑的入口在这里,最初的参数初始化是在为Director设置 GLView实例时进行的:

bool AppDelegate::applicationDidFinishLaunching() {
    // initialize director
    CCDirector* pDirector = CCDirector::sharedDirector();
    CCEGLView* pEGLView = CCEGLView::sharedOpenGLView();

    pDirector->setOpenGLView(pEGLView);
    CCSize frameSize = pEGLView->getFrameSize();
    … …
}

void CCDirector::setOpenGLView(CCEGLView *pobOpenGLView)
{
        m_pobOpenGLView = pobOpenGLView;

        // set size
        m_obWinSizeInPoints =
           m_pobOpenGLView->getDesignResolutionSize();
        … …

        if (m_pobOpenGLView)
        {
            setGLDefaultValues();
        }

        CHECK_GL_ERROR_DEBUG();
        … …
    }
}

由于尚未调用setDesignResolutionSize,因此m_obWinSizeInPoints的值与FrameSize大小相 同。

setGLDefaultValues最为关键,这是我们第一次遇到该函数,该方法用于初始化一些OpenGL的参数,建立好后续 OpenGL操作时所需要的各种数据结构。

void CCDirector::setGLDefaultValues(void)
{
    … …
    setAlphaBlending(true);
    setDepthTest(false);
    setProjection(m_eProjection);
    // set other opengl default values
    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}

glClearColor(0.0f, 0.0f, 0.0f, 1.0f);设置初始颜色为黑色,alpha为1.0f,即完全不透明。setProjection是实际上绘制参数设置的核心。

void CCDirector::setProjection(ccDirectorProjection kProjection)
{
    CCSize size = m_obWinSizeInPoints;

    setViewport();
   
    switch (kProjection)
    {
    case kCCDirectorProjection3D:
        {
            float zeye = this->getZEye();

            kmMat4 matrixPerspective, matrixLookup;

            kmGLMatrixMode(KM_GL_PROJECTION);
            kmGLLoadIdentity();

            … …

            // issue #1334
            kmMat4PerspectiveProjection( &matrixPerspective,
                   60,
                  (GLfloat)size.width/size.height,
                   0.1f, zeye*2);

            kmGLMultMatrix(&matrixPerspective);

            kmGLMatrixMode(KM_GL_MODELVIEW);
            kmGLLoadIdentity();
            kmVec3 eye, center, up;
            kmVec3Fill( &eye, size.width/2,
                   size.height/2, zeye );
            kmVec3Fill( &center, size.width/2,
                   size.height/2, 0.0f );
            kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
            kmMat4LookAt(&matrixLookup, &eye,
                         &center, &up);
            kmGLMultMatrix(&matrixLookup);
        }
        break;
        … …
    }

    m_eProjection = kProjection;
    ccSetProjectionMatrixDirty();
}

由于前面m_eProjection已经被赋值为kCCDirectorProjection3D,因此我们只分析 kCCDirectorProjection3D这个case分支。该函数大致进行设置的顺序是:设置视口变换(ViewPort)、设置投影变换矩阵和 设置模型视图变换矩阵。我们分别来看:

 * 设置视口(ViewPort)

void CCDirector::setViewport()
{
    if (m_pobOpenGLView)
    {
        m_pobOpenGLView->setViewPortInPoints(0, 0,
              m_obWinSizeInPoints.width,
              m_obWinSizeInPoints.height);
    }
}

void CCEGLViewProtocol::setViewPortInPoints(float x ,
                     float y , float w , float h)
{
    glViewport((GLint)(x * m_fScaleX
               + m_obViewPortRect.origin.x),
               (GLint)(y * m_fScaleY
               + m_obViewPortRect.origin.y),
               (GLsizei)(w * m_fScaleX),
               (GLsizei)(h * m_fScaleY));
}

这是我们遇到的第一个OpenGL概念:设置视口变换,关于视口变换究竟起到什么作用,后续会细说。

 * 设置“投影变换”矩阵参数

 kmMat4PerspectiveProjection( &matrixPerspective, 60,
        (GLfloat)size.width/size.height, 0.1f, zeye*2);
 kmGLMultMatrix(&matrixPerspective);

 * 设置“模型视图变换”矩阵参数

 kmVec3 eye, center, up;
 kmVec3Fill( &eye, size.width/2,
             size.height/2, zeye );
 kmVec3Fill( &center, size.width/2,
             size.height/2, 0.0f );
 kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
 kmMat4LookAt(&matrixLookup, &eye,
             &center, &up);

至此,引擎的绘制参数初始化设置就OK了,在你调用setDesignResolutionSize之前,这些参数不会被改变。

二、kazmath

Cocos2d-x引擎最底层采用OpenGL ES 2.0进行图形绘制,这样要想搞清楚前面的问题缘由,对OpenGL那一套技术体系至少要有一些直观认识才行。在这之前,我们还要先了解一些 Cocos2d-x深度使用的kazmath库。根据《Cocos2d-x高级开发教程》书 中说: “因为在Cocos2d-x 2.0采用的OpenGL ES 2.0中,而那些OpenGL ES 1.0函数已经不可使用了。但OpenGL ES 2.0已经放弃了固定的渲染流水线,取而代之的是自定义的各种着色器,在这种情况下变换操作通常需要由开发者来维护。所幸引擎也引入了一套第三方库 Kazmath,它使得我们几乎可以按照原来OpenGL ES 1.0所采用的方式进行开发”。

至此,我们大致知道了Kazmath库是用来辅助我们按照OpenGL ES 1.0的方式管理变换矩阵以及做变换操作的,接下来我们一起来看看kazmath库的结构吧:

//cocos2d-x-2.2.2/cocos2dx/kazmath/src/GL/matrix.c

km_mat4_stack modelview_matrix_stack;
km_mat4_stack projection_matrix_stack;
km_mat4_stack texture_matrix_stack;
km_mat4_stack* current_stack = NULL;
static unsigned char initialized = 0;

以上是Cocos2d-x整个引擎生命周期内会用到的与opengl变换矩阵相关的一些全局变量。

kazmath声明了三个变换矩阵的栈,modelview_matrix_stack(模型视图矩阵栈)、 projection_matrix_stack(投影矩阵栈)以及texture_matrix_stack(纹理矩阵栈)。不过Cocos2d-x引 擎只用到了前两个变化矩阵栈。current_stack指向当前所使用的那个变换矩阵栈。

这些栈的初始化在lazyInitialize中:

void lazyInitialize()
{

    if (!initialized) {
        kmMat4 identity; //Temporary identity matrix

        //Initialize all 3 stacks
        //modelview_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&modelview_matrix_stack);

        //projection_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&projection_matrix_stack);

        //texture_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&texture_matrix_stack);

        current_stack = &modelview_matrix_stack;
        initialized = 1;

        kmMat4Identity(&identity);

        //Make sure that each stack has the identity matrix
        km_mat4_stack_push(&modelview_matrix_stack, &identity);
        km_mat4_stack_push(&projection_matrix_stack, &identity);
        km_mat4_stack_push(&texture_matrix_stack, &identity);
    }
}

kmMat4Identify用于初始化“单位矩阵(Indentify Matrix)”,所谓"单位矩阵",指的是对脚线上元素都为1的矩阵。从kmMat4Identify的实现,我们也可以看出这一点:

kmMat4* const kmMat4Identity(kmMat4* pOut)
{
    memset(pOut->mat, 0, sizeof(float) * 16);
    pOut->mat[0] = pOut->mat[5]
     = pOut->mat[10]
     = pOut->mat[15] = 1.0f;

    return pOut;
}

最后,lazyInitialize函数将单位矩阵分别圧入(km_mat4_stack_push)不同的matrix stack。

再回顾一下CCDirector::setProjection,该函数通过kazmath先后设置了 projection_matrix_stack和modelview_matrix_stack的top元素。

   kmGLMatrixMode(KM_GL_PROJECTION);
   kmGLLoadIdentity();
   kmMat4PerspectiveProjection( &matrixPerspective, 60,
     (GLfloat)size.width/size.height, 0.1f, zeye*2);
   kmGLMultMatrix(&matrixPerspective);
  
   kmGLMatrixMode(KM_GL_MODELVIEW);
   kmGLLoadIdentity();
   kmVec3 eye, center, up;
   kmVec3Fill( &eye, size.width/2,
               size.height/2, zeye );
   kmVec3Fill( &center, size.width/2,
               size.height/2, 0.0f );
   kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
   kmMat4LookAt(&matrixLookup, &eye,
               &center, &up);
   kmGLMultMatrix(&matrixLookup);

三、精灵绘制

由《Hello,Cocos2d-x》一文我们知道,一旦引擎初始化完毕,就开始了每帧图像的绘制工作,Render Thread在一个“死循环”中反复调用CCDirector的drawScene方法 (CCDisplayLinkDirector::mainLoop中调用了drawScene):

void CCDirector::drawScene(void)
{
    … …
    glClear(GL_COLOR_BUFFER_BIT
           | GL_DEPTH_BUFFER_BIT);
    … …
    kmGLPushMatrix();

    // draw the scene
    if (m_pRunningScene)
    {
        m_pRunningScene->visit();
    }
    … …
    kmGLPopMatrix();
    … …
}

Cocos2d-x采用“渲染树”的方式进行绘制,即先从场景(Scene)的顶层根节点开始,深度优先的递归绘制Child Node。而整个绘制的顶层节点是CCScene。绘制从m_pRunningScene->visit()真正开始。visit是Scene、 Layer、Sprite的共同父类CCNode实现的方法:

void CCNode::visit()
{
    if (!m_bVisible)
    {
        return;
    }
    kmGLPushMatrix();
    … …
    this->transform();
    … …
   
    if(m_pChildren &&
       m_pChildren->count() > 0)
    {
        sortAllChildren();
        // draw children zOrder < 0
        … ..
        // self draw
        this->draw();

        // draw other children nodes
        … …
    } else {
        this->draw();
    }
    … …
    kmGLPopMatrix();
}
   
Visit大致做了这么几件事:
    – 向当前OpenGL变换矩阵栈Push元素
    – 用当前OpenGL变换矩阵栈栈顶元素的变换参数做节点变换
    – 递归绘制zOrder < 0 的子节点
    – 绘制自己
    – 递归绘制其他子节点
    – 从当前OpenGL变换矩阵栈Pop元素

如果你想知道为什么父节点缩放(Scale)、旋转(Rotate)、扭曲(Skew)后,子节点也会跟着父节点同样缩放(Scale)、旋 转(Rotate)、扭曲?其原理就在这里的transform方法中:

void CCNode::transform()
{
    kmMat4 transfrom4x4;

    // Convert 3×3 into 4×4 matrix
    CCAffineTransform tmpAffine
       = this->nodeToParentTransform();
    CGAffineToGL(&tmpAffine,
                 transfrom4x4.mat);

    // Update Z vertex manually
    transfrom4x4.mat[14] = m_fVertexZ;

    kmGLMultMatrix( &transfrom4x4 );
    … …
}

在进入tranform以前,Cocos2d-x做了啥?对了,kmGLPushMatrix():

void kmGLPushMatrix(void)
{
    kmMat4 top;

    lazyInitialize();

    //Duplicate the top of the stack (i.e the current matrix)
    kmMat4Assign(&top, current_stack->top);
    km_mat4_stack_push(current_stack, &top);
}

在引擎初始化后,我们的current_stack是模型视图矩阵栈modelview_matrix_stack。所有设置的初始参数都保 存在该栈的栈顶元素中。在每次Node绘制前,Node都会创建自己的变换矩阵,但这个矩阵不是凭空创造的,从kmGLPushMatrix 可以看出,在当前Node将新创建的矩阵元素圧栈前,它复制了原栈顶元素,也就携带有父节点所有的初始变换信息,也就是说在 km_mat4_stack_push后,栈顶放置的元素其实是原栈顶元素的复制品,而后续所有操作都是基于这个复制品的。这样一来,如果父 节点做了缩放或旋转或扭曲,那这些信息都会作为初始信息作为子节点变换的基础,后续子节点自身的变换参数也都是在这个基础上做出的,最终的矩 阵是transform方法中的kmGLMultMatrix后得出的。真正的矩阵变换计算都在nodeToParentTransform 中,不过要想看懂这个函数,需要对OpenGL有更深入的了解才行,这里略过^_^。

真正绘制Node的方法是CCNode::draw的override方法。CCNode::draw是一个空函数,各个子类 override该方法进行各自的绘制。以CCSprite::draw为例:

void CCSprite::draw(void)
{
    CC_NODE_DRAW_SETUP();

    ccGLBlendFunc( m_sBlendFunc.src, m_sBlendFunc.dst );

    ccGLBindTexture2D( m_pobTexture->getName() );
    ccGLEnableVertexAttribs( kCCVertexAttribFlag_PosColorTex );

#define kQuadSize sizeof(m_sQuad.bl)
    long offset = (long)&m_sQuad;

    // vertex
    int diff = offsetof( ccV3F_C4B_T2F, vertices);
    glVertexAttribPointer(kCCVertexAttrib_Position, 3,
     GL_FLOAT, GL_FALSE, kQuadSize, (void*) (offset + diff));

    // texCoods
    diff = offsetof( ccV3F_C4B_T2F, texCoords);
    glVertexAttribPointer(kCCVertexAttrib_TexCoords, 2,
      GL_FLOAT, GL_FALSE, kQuadSize, (void*)(offset + diff));

    // color
    diff = offsetof( ccV3F_C4B_T2F, colors);
    glVertexAttribPointer(kCCVertexAttrib_Color, 4,
           GL_UNSIGNED_BYTE, GL_TRUE,
           kQuadSize, (void*)(offset + diff));

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    … …
}

这里的draw是一个典型的OpenGL绘制工序。CC_NODE_DRAW_SETUP()将之前的经过若干准备而得到的最终各类变换矩阵 整合并传给OpenGL:

/** @def CC_NODE_DRAW_SETUP
 Helpful macro that setups the GL server state,
 the correct GL program and sets the Model View
 Projection matrix
 @since v2.0
 */
#define CC_NODE_DRAW_SETUP() \
do { \
    ccGLEnable(m_eGLServerState); \
    CCAssert(getShaderProgram(), "No shader program set for this node"); \
    { \
        getShaderProgram()->use(); \
        getShaderProgram()->setUniformsForBuiltins(); \
    } \
} while(0)

void CCGLProgram::setUniformsForBuiltins()
{
    kmMat4 matrixP;
    kmMat4 matrixMV;
    kmMat4 matrixMVP;

    kmGLGetMatrix(KM_GL_PROJECTION, &matrixP);
    kmGLGetMatrix(KM_GL_MODELVIEW, &matrixMV);

    kmMat4Multiply(&matrixMVP, &matrixP, &matrixMV);

    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformPMatrix],
                                    matrixP.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVMatrix],
                                    matrixMV.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVPMatrix],
                                    matrixMVP.mat, 1);
    … …
}

经过计算顶点、绑定纹理等步骤后,最终由glDrawArrays完成Node绘制。

四、m_fScaleX和m_fScaleY都是1.0,背景精灵为何被放大?

根据上面的分析,我们了解到“子节点将跟随父节点的缩放而缩放”。据此,我们来分析一下前面提到的屏幕适配例子中的第三种情况,即屏幕大小为 960×640,按照Cocos2d-x屏幕适配指南Wiki中的做法,调用 pEGLView->setDesignResolutionSize(480, 320)。在该情况中,我们得到的结果是480×320大小的背景图片充满了大小为960×640的屏幕窗口,这给我们的直观印象就是背景图片被放大了一 倍。下面我们就尝试用上面的分析来解释一下这个现象。

在这个例子中,渲染树结构如下:
   CCScene
        – CCLayer
            – CCSprite – 背景图精灵

按照之前的理论,背景图精灵自身或父类应该有缩放的设置,比如m_fScaleX = 2.0之类的设置,于是我在代码中输出了Scene、Layer以及Sprite的m_fScaleX和m_fScaleY值。但出乎预料的是,这些 Node子类的两个轴向缩放值都保持了默认值,即1.0f。在代码里翻了半天,也的确没有找到改写Scene、Layer或Sprite Scale的地方。又一想:代码中调用了setDesignResolutionSize,这样CCEGLView的m_fScaleX = m_fScaleY = 2.0f,难道是CCEGLView的m_fScale传递给了CCScene等Node子类,但事实总是残酷的,代表这一联系的代码也始终未被我所找 到,看来继续纠结m_fScale的值设置是无法搞清楚真正原因,应该换换思路了。这里背景图的放大不应该是Node scale值设置的问题,也就是说关键环节不应该在绘制流程,而是在之前的OpenGL变换矩阵参数设置,看来不再深入学习点OpenGL知识,这个问题 就很难搞定了,于是开始翻看《OpenGL编程指南7th》(号称OpenGL红宝书)和《OpenGL超级宝典》(号称OpenGL蓝宝 书)。虽然我的阅读是粗粒度的,但还是收获到了一些答案。

五、OpenGL基础

OpenGL是帮助我们将三维世界的物体转换到二维屏幕上的一组接口。在新技术尚未出现之前,我们的屏幕永远是二维的,即便是现在的3D电影 也是双眼视角二维图像叠加的结果。我们知道“将大象装进冰箱总共分三 步”,将一个三维模型转换到二维屏幕上,OpenGL也规定了相对流水线般的步骤。

OpenGL三维图形的显示流程

三维图形显示流程中,涉及到OpenGL的一个重要操作,那就是“变换(Transformation)”,主要的变换包括模型视图变换 (model-view transformation)、投影变换(projection transformation)以及视口变换(ViewPort transformation)。我们经常用相机模拟来对比OpenGL解决这一问题的过程以及相关概念。

回顾一下我们自己用相机拍照的步骤吧。

第零步,选景。景就是所谓的三维模型或三维物体,或简称模型(Model),就是我们要显示到屏幕上的物体;
第一步,确定相机位置。让相机以一定的距离、高度、角度对准模型。在这里,相机的位置变换,对应OpenGL的“视图变换或叫视点变换 (View Transformation)”。在这一步里(对应上面图中的第二步),我们还可以调整三维物体的相对位置、角度与相机的距离,这就是模型变换 (Modeling Transformation),两种变换达成的效果是相同的,因此总称模型视图变换(Model-View Transformation)。
第二步,选镜头,并调焦。确定图像投影在胶片上的范围以及景深等。这一步叫投影变换(Projection Transformation)。
第三步,冲洗照片。拍摄好的图像放在底片上,但我们需要选择冲洗后最终是放在6寸相纸还是20寸相纸上,显然在不同大小相纸上,图像的显示效 果不同(比如大小)。这个过程叫视口变换(Viewport Transformation)。

三维空间的物体都是用三维坐标描述的,谈到坐标就离不开坐标系,OpenGL中的坐标系就有多种,我们最常用的就是世界坐标系。

世界坐标系是以屏幕中心为原点(0, 0, 0),你面对屏幕,你的右边是x正轴,上面是y正轴,屏幕指向你的为z正轴。无论如何变换,世界坐标系都不动。我们在Cocos2d-x中设置 初始参数时,参数的单位多为世界坐标系中的单位。

视点变换时会涉及到视点坐标系,但这个变换由opengl接口来负责,我们不用过多关心。

绘图坐标系(局部坐标系),当前绘图坐标系是绘制物体时的坐标系。程序刚初始化时,世界坐标系和当前绘图坐标系是重合的,当用 glTranslatef()等变换函数做移动和旋转时,都是改变的当前绘图坐标系,改变的位置都是当前绘图坐标系相对自己的x,y,z轴所做的 改变,改变以后,再绘图时,都是在当前绘图坐标系进行绘图,所有的函数参数也都是相对当前绘图坐标系来讲的。

屏幕坐标系,即终端屏幕上的坐标系,与世界坐标系有不同,它以屏幕左上角的点为原点,向右是x正轴,向下是y正轴,屏幕指向你的为z正轴。

注意视口(Viewport)的设置是以实际屏幕坐标定义了窗口中的区域,长度宽度都是以实际像素为单位。当然引擎在精灵绘图时用 的是绘图坐标系,我们理解原点在左下角即可。

六、Cocos2d-x各种变换矩阵的初始参数设置

前面说过,Cocos2d-x在CCDirector::setProjection中完成了对变换矩阵的初始参数设置,我们逐一来看看这些设置对模型映射后的二维图像有何影响,这也是理解篇头几个问题的关键环节。

  * 投影变换
   
    前面提到过,投影变换相当于调节相机镜头。OpenGL中提供了两种投影方式,一种是正射投影,另一种是透视投影。Cocos2d-x使用的是透视投影 (Perspective Projection)。透视投影是实际人们观察事物的真实反馈,即离视点近的物体大,离视点远的物体小,远到极点即为消失,成为灭点。Cocos2d- x使用的是kmMat4PerspectiveProjection,对应OpenGL中的gluPerspective,该方法创建一个对称透视视景体 (View Volumn),见下图:

gluPerspective的函数原型如下:void gluPerspective(GLdouble fovy,GLdouble aspect,GLdouble zNear, GLdouble zFar);

    参数fovy定义视野在X-Z平面的角度,范围是[0.0, 180.0],也就是上图中的“视角”;
    参数aspect是投影平面宽度与高度的比率;
    参数zNear和Far分别是近远裁剪面沿Z负轴到视点的距离,它们总为正值。
  
Cocos2d-x中是这么设置投影变换矩阵的:

  float zeye = this->getZEye();
  kmMat4PerspectiveProjection( &matrixPerspective, 60, (GLfloat)size.width/size.height, 0.1f, zeye*2);

  float CCDirector::getZEye(void)
  {
    return (m_obWinSizeInPoints.height / 1.1566f);
  }

从参数上来看,
    视角 = 60度
    宽高比 = 设计分辨率的宽高比,
    近平面 = 距离视点0.1f,几乎与视点重合
    远平面 = 距离视点zeye * 2距离。
    视点位置 = 设计分辨率.height / 1.1566f

投影是用来对模型进行截取的,只有在投影变换所建立的平头截体(Frustum,投影的近、远两个截面以及其他四个面构成的立体体)内的模型部分才会被最终映射和显示。我们用下面的图来直观了解一下各个参数在三维空间的概念吧。

显然引擎如此设置投影矩阵的参数是有考虑的:
首先就是投影平头截体的宽高比 = 设计分辨率的宽高比,这样设置使得一切符合设计分辨率宽高比的模型都可以被理想截取。
其次,视角60度,zEye的在Z轴正方向距离世界原点的距离 = (m_obWinSizeInPoints.height / 1.1566f),这里的1.1566f是怎么来的呢?我们沿着X轴负方向向zy平面投影,得到下图:

看这个图,让我想起了初中几何,通过60度的视角,我们可以推断由eye、XZ截断上平面与Y轴的交点、XZ截断下平面与Y轴的交点组成一个等边三角形, 现在我们已知在Zy平面投影中视点与原点的距离为m_obWinSizeInPoints.height / 1.1566f, 我们还知道夹角是60度,我们求一下投影在(z=0,XY平面)的截面高度h。

cos30 = (m_obWinSizeInPoints.height / 1.1566f)/ h
h = (m_obWinSizeInPoints.height / 1.1566f)/cos30 = m_obWinSizeInPoints.height;

我们计算出来的结果是 h = m_obWinSizeInPoints.height = 设计分辨率中的高度分量。这意味这什么呢?Cocos2d-x是2D游戏渲染引擎,针对该引擎的模型的z坐标都是0,因此模型实际上就在xy平面内,也就 是说eye与原点的距离恰好就是eye与模型的距离,而模型可显示区域的最大高度也就是h,即m_obWinSizeInPoints.height。这 个结论会在后续问题分析时发挥作用。

注意虽然这里知道eye在Z轴正方向距离世界原点的距离,但eye的(x, y)坐标在投影设置后依旧无法确认,我们需要在设置模型视图变换时得到eye的(x, y)坐标。

  * 视图变换

    kmGLMatrixMode(KM_GL_MODELVIEW);
    kmGLLoadIdentity();
    kmVec3 eye, center, up;
    kmVec3Fill( &eye, size.width/2, size.height/2, zeye );
    kmVec3Fill( &center, size.width/2, size.height/2, 0.0f );
    kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
    kmMat4LookAt(&matrixLookup, &eye, &center, &up);
    kmGLMultMatrix(&matrixLookup);

OpenGL原生的视图变换参数设置方法是gluLookAt,在kazmath中对应的方法为kmMat4LookAt。gluLookAt的函数原型是:

    void gluLookAt(GLdouble eyex, GLdouble exey, GLdouble eyez,
       GLdouble centrex, GLdouble centrey, GLdouble centrez,
       GLdouble upx, GLdouble upy, GLdouble upz);

eye的坐标(eyex, eyey, eyez), Cocos2d-x中是这么设置的kmVec3Fill( &eye, size.width/2, size.height/2, zeye )。可以看出eye在xy平面的投影恰好是以屏幕分辨率构成的矩形的中心。

centre坐标,表示的是视线方向,该方向矢量是由eye坐标、centre坐标共同构成的,由eye指向center。Cocos2d-x的设置 kmVec3Fill( &center, size.width/2, size.height/2, 0.0f )。x, y坐标与eye的相同,因此视线平行于Z轴。

最后的up参数可以理解为头顶方向,这里设置为Y轴方向。

可以看出,eye就在投影区的中心,由于投影区的高度为size.height(投影变换时分析得到的),这样根据投影矩阵设置的宽高比,得出该投影区的宽度也恰为size.width。

七、再分析

有了以上关于Cocos2d-x引擎的了解,我们再回过头来用OpenGL的变换原理对篇头的三种情况做分析。

 1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图充满窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域恰好是480×320;
        背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合,显示在屏幕上后,背景图恰充满窗口,见下图:

   
   
 2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图未充满窗口,四周有较大黑边。
 
    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(480, 320, 480/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);

    因此背景图(480×320)未能完整充满截面区域(960×640),背景图周围将有较大黑边,见下图:
   
     

 3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320)。结果:背景图放大为原来2倍,充满屏幕窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域是480×320;
        而背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合。但这里需要注意的是现在屏幕是960×640,而截面区域仅仅是480×320,为何映射后,背景图充满屏幕了呢?这里就不能不提到视口的作用了。

    前面说过视口相当于相片,现在我们拍摄出的图片是480×320的,但我们选择的底片Viewport却是960×640的,怎么办,在视口转换 时,OpenGL自动将480×320的图片映射到960×640的底片上,相当于对图像进行的放大。而960×640的视口恰好与屏幕窗口大小一致且坐 标重叠,于是我们就在屏幕上看到了一个铺满屏幕的背景图,见下图:

   

 4) 我们再来说两个有关视口的例子

    以第三种情况为基础,我们修改一下引擎代码,看看视口的作用。
   
    我们手工将CCDirector::setViewport()中的:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width, m_obWinSizeInPoints.height);
    改为:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width/2, m_obWinSizeInPoints.height/2);

    这样修改后,Viewport从point(0,0), rect (960×640)变成了point(0,0), rect (480×320)。也就是说用照相机拍出的景物大小是480×320,底片也是480×320,但屏幕是960×640,我们可以将屏幕理解为相框,把 一张480×320的照片,放到960×640大小的相框里,相片只能占据相框的四分之一。这个例子的最终屏幕显示结果见下图:

   

    前面的例子中背景图片size均小于屏幕大小,我们再来举一个资源图片大于屏幕大小的例子,看看经过一系列变换会得到什么样的结果。
   
    首先将CCDirector::setViewport()中的代码恢复原先状态。然后我们准备一张1024×768(>屏幕的960×640)的 背景图片"HelloWorld-1024×768.jpg",修改HelloWorldScene.cpp,将:
    CCSprite* pSprite = CCSprite::create("HelloWorld.png");
    修改为:
    CCSprite* pSprite = CCSprite::create("HelloWorld-1024×768.png");

    注释掉AppDelegate.cpp中的pEGLView->setDesignResolutionSize调用,这样更直观。

    这样修改后,各参数如下:
        eye视点坐标(480, 320, 640/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);
        Viewport point(0,0), rect (960×640)
   
    由于背景资源图片太大(1024×768),大于我们的投影截面区域960×640,因此模型真正能显示的部分仅仅是投影截面区域中的那960×640范围内的图片。于是显示结果如下:

   

    矩阵变换过程如下:

   

    投影截面区域与视口区域重叠,这里就不再赘述了。

八、CCDirector::m_fContentScaleFactor

决定图像在屏幕上的最终显示结果的因素还有一个,那就是CCDirector::m_fContentScaleFactor。在最初的HelloCpp例子中,我们能看到这样的代码:

    if (frameSize.height > mediumResource.size.height)
    {
        searchPath.push_back(largeResource.directory);
        pDirector->setContentScaleFactor(
          MIN(largeResource.size.height/designResolutionSize.height,
              largeResource.size.width/designResolutionSize.width));
    }
    … …

    可以看出这个contentScaleFactor存储的是资源分辨率与设计分辨率的比值。我们还是用例子来看看该元素对显示的影响。我们在第一种情况的基础上验证。

    第一种情况:屏幕480×320,未调用setDesignResolutionSize,资源大小480×320。结果:图片充满屏幕。

    现在我们增加并使用一个新资源:HelloWorld-960×640.png,这个图片大小960×640,是屏幕大小的二倍,根据上面的分析,我们很容易猜测到最终结果是:只有图片中央区域(480×320)可以显示出来,其余部分被投影矩阵截掉。

    现在我们使用setContentScaleFactor,在AppDelegate.cpp中做如下调用:

    pDirector->setContentScaleFactor(MIN(960/480, 640/320));

    这样我们得到的m_fContentScaleFactor = 2。而我们编译运行后得到的结果是:图片铺满整个屏幕。为什么会这样呢?

    我们在代码中搜索contentScaleFactor,我们找到一些宏和调用:

   
#define CC_CONTENT_SCALE_FACTOR() CCDirector::sharedDirector()->getContentScaleFactor()

CCSize CCTexture2D::getContentSize()
{

    CCSize ret;
    ret.width = m_tContentSize.width / CC_CONTENT_SCALE_FACTOR();
    ret.height = m_tContentSize.height / CC_CONTENT_SCALE_FACTOR();

    return ret;
}

#define CC_RECT_PIXELS_TO_POINTS(__rect_in_pixels__)                                                                        \
    CCRectMake( (__rect_in_pixels__).origin.x / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).origin.y / CC_CONTENT_SCALE_FACTOR(),    \
            (__rect_in_pixels__).size.width / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).size.height / CC_CONTENT_SCALE_FACTOR() )

… …

bool CCSprite::initWithTexture(CCTexture2D *pTexture)
{
    CCAssert(pTexture != NULL, "Invalid texture for sprite");

    CCRect rect = CCRectZero;
    rect.size = pTexture->getContentSize();

    return initWithTexture(pTexture, rect);
}

    这些代码都在告诉我们,如果m_fContentScaleFactor = 2,那代码会对Sprite的纹理进行缩放,让上面得到的数据是经过contentScaleFactor变换的,我们可以认为我们所用的实际资源大小是 原资源的1/m_fContentScaleFactor即可。




这里是Tony Bai的个人Blog,欢迎访问、订阅和留言!订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您喜欢通过微信App浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:



本站Powered by Digital Ocean VPS。

选择Digital Ocean VPS主机,即可获得10美元现金充值,可免费使用两个月哟!

著名主机提供商Linode 10$优惠码:linode10,在这里注册即可免费获得。

阿里云推荐码:1WFZ0V立享9折!

View Tony Bai's profile on LinkedIn


文章

评论

  • 正在加载...

分类

标签

归档











更多